Phone Number in Crystal: Complete Solution & Deep Dive Guide

text

From Messy Strings to Clean Numbers: A Deep Dive into Crystal Phone Number Validation

Parsing and validating user-provided data is a cornerstone of robust software development. This guide provides a comprehensive walkthrough on how to clean, validate, and format North American Numbering Plan (NANP) phone numbers using the Crystal programming language, transforming inconsistent inputs into a standardized, usable format.


The Universal Problem: Taming Unruly Data

Picture this: you're building a new user registration system or importing a large contact list. The phone number field, a seemingly simple piece of data, arrives in a chaotic assortment of formats. You see (223) 456-7890, 223.456.7890, 1 223 456 7890, and even some entries with extraneous text like "My number is 223-456-7890". Your application needs to store and use these numbers consistently, perhaps to send SMS notifications via an API that only accepts a specific format.

This inconsistency is more than a minor annoyance; it's a direct threat to data integrity and application functionality. Storing messy data leads to bugs, failed API calls, and a poor user experience. The solution is to implement a strict, intelligent cleaning and validation layer right at the point of data entry. This is where the power and elegance of Crystal come into play.

In this deep dive, we'll build a complete solution from the ground up. We will explore Crystal's powerful string manipulation capabilities and logical constructs to create a bulletproof phone number parser. By the end, you'll have not just the code, but a deep understanding of the principles behind data sanitization that you can apply to countless other programming challenges.


What is the North American Numbering Plan (NANP)?

Before writing any code, it's crucial to understand the rules of the data we're working with. The North American Numbering Plan (NANP) governs the phone numbering system for the United States, Canada, and many Caribbean countries. Understanding its structure is key to building an accurate validator.

A standard NANP number is a 10-digit number structured as follows:

  • Area Code (NPA): The first 3 digits.
  • Exchange Code (NXX): The next 3 digits.
  • Subscriber Number (Line Number): The final 4 digits.

This structure is often written as NPA-NXX-XXXX. Our validator must enforce a few critical rules derived from the NANP specification:

  1. Length: The number must be exactly 10 digits long.
  2. Country Code: An 11-digit number is acceptable only if the first digit is 1 (the country code for NANP regions). In this case, the 1 should be stripped, leaving the core 10-digit number.
  3. Invalid Area/Exchange Codes: The first digit of the Area Code (NPA) cannot be 0 or 1.
  4. Invalid Exchange Codes: Similarly, the first digit of the Exchange Code (NXX) cannot be 0 or 1.
  5. Punctuation and Whitespace: The validator must ignore any non-digit characters like parentheses (), hyphens -, periods ., and spaces.

Our goal is to create a Crystal program that takes any string as input and, if it contains a valid NANP number according to these rules, returns the clean 10-digit string. Otherwise, it should identify the number as invalid.

ASCII Diagram: The Structure of a NANP Number

This diagram visualizes the components of a valid phone number string that our parser will deconstruct.

● Input String: "+1 (223) 456-7890"
│
▼
┌──────────────────┐
│  Optional Prefix │
│  (+1, 1)         │
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│  Area Code (NPA) │
│  (Digits 2-4)    │
│  Cannot start    │
│  with 0 or 1.    │
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│ Exchange Code(NXX)│
│  (Digits 5-7)    │
│  Cannot start    │
│  with 0 or 1.    │
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│ Subscriber Number│
│  (Digits 8-11)   │
└────────┬─────────┘
         │
         ▼
● Clean Output: "2234567890"

Why is Phone Number Sanitization a Non-Negotiable Task?

In modern software engineering, data is the lifeblood of applications. The quality of this data directly impacts system reliability, user trust, and business outcomes. Phone number sanitization is a specific but critical example of the broader practice of data validation.

  • API Integration: External services, such as Twilio for SMS or Stripe for payments, have strict API requirements. Sending a malformed phone number like "(223) 456-7890" instead of the required E.164 format (e.g., +12234567890) will result in a failed API call, wasted resources, and a broken user feature.
  • Database Consistency: Storing numbers in a uniform format (e.g., only the 10 digits) makes querying and indexing vastly more efficient. It prevents duplicate entries where 223-456-7890 and (223)4567890 are treated as two different contacts.
  • User Experience: When a user enters their phone number, providing immediate feedback on its validity prevents them from submitting incorrect data and wondering why they never received a confirmation text.
  • Security: While less direct, validating input formats is a fundamental principle of security. It helps prevent injection attacks or unexpected behavior that could arise from processing malformed data.

By enforcing validation at the entry point, you build a more resilient and predictable system. Crystal, with its static typing and expressive syntax, provides an excellent toolset for building such validation layers.


How to Build the NANP Validator in Crystal

Now, let's dive into the code. We will create a PhoneNumber struct in Crystal. This approach encapsulates the logic and provides a clean, reusable interface for handling phone numbers throughout an application. This solution is part of the exclusive learning curriculum from kodikra.com.

Our design will feature:

  • An initialize method to handle the initial parsing and cleaning.
  • A number method to return the clean 10-digit string or an invalid marker.
  • An area_code method for easy access to that specific part.
  • A to_s method for a nicely formatted string representation.

The Complete Crystal Solution

Here is the full, commented code for our PhoneNumber struct. We'll break it down piece by piece in the next section.

# Solution from kodikra.com learning path
# This struct handles parsing, validation, and formatting of NANP phone numbers.
struct PhoneNumber
  # The cleaned 10-digit number, or a placeholder for invalid numbers.
  @cleaned_number : String
  # A constant to represent an invalid number, making it easy to check.
  private INVALID_NUMBER = "0000000000"

  # The constructor takes a raw string and processes it immediately.
  def initialize(raw_phrase : String)
    # 1. Sanitize: Extract only digits from the input string.
    #    The regex `/\d/` matches any digit character (0-9).
    #    `scan` returns an array of all matches. `join` concatenates them.
    digits = raw_phrase.scan(/\d/).join

    # 2. Validate and Clean: Apply NANP rules.
    @cleaned_number = case digits.size
    when 10
      # If 10 digits, it's potentially valid. Proceed to content validation.
      validate_content(digits)
    when 11
      # If 11 digits, it's only valid if it starts with country code '1'.
      if digits.starts_with?('1')
        # Strip the country code and validate the remaining 10 digits.
        validate_content(digits[1..-1])
      else
        # 11 digits but not starting with '1' is invalid.
        INVALID_NUMBER
      end
    else
      # Any other length is automatically invalid.
      INVALID_NUMBER
    end
  end

  # Private helper method to validate the content of a 10-digit number string.
  # This enforces the rules about area codes and exchange codes.
  private def validate_content(ten_digit_string : String)
    # The first digit of the area code (index 0) cannot be '0' or '1'.
    area_code_valid = !{'0', '1'}.includes?(ten_digit_string[0])
    # The first digit of the exchange code (index 3) cannot be '0' or '1'.
    exchange_code_valid = !{'0', '1'}.includes?(ten_digit_string[3])

    if area_code_valid && exchange_code_valid
      # If both are valid, return the number.
      ten_digit_string
    else
      # Otherwise, it's an invalid number.
      INVALID_NUMBER
    end
  end

  # Public method to return the cleaned 10-digit number.
  def number : String
    @cleaned_number
  end

  # Public method to extract and return the 3-digit area code.
  def area_code : String
    # Slices the first 3 characters from the cleaned number.
    @cleaned_number[0, 3]
  end

  # Overrides the default `to_s` method to provide a formatted string.
  # Example output: "(223) 456-7890"
  def to_s(io : IO)
    io << "(" << area_code << ") " << @cleaned_number[3, 3] << "-" << @cleaned_number[6, 4]
  end
end

Running the Code

To test this solution, save the code as src/phone_number.cr. You can then create a simple test file or run it directly to see it in action.

Create a file runner.cr:

require "./src/phone_number"

def test_number(description, input)
  phone = PhoneNumber.new(input)
  puts "#{description.ljust(45)} -> Number: #{phone.number.ljust(12)} | Formatted: #{phone.to_s}"
end

puts "--- Testing Valid Numbers ---"
test_number("Standard 10-digit number", "2234567890")
test_number("Number with punctuation", "(415) 555-1234")
test_number("Number with dots", "888.555.1212")
test_number("Valid 11-digit number with country code", "1 937 555 1299")

puts "\n--- Testing Invalid Numbers ---"
test_number("Invalid: letters in number", "123-abc-7890")
test_number("Invalid: 9 digits", "123456789")
test_number("Invalid: 11 digits not starting with 1", "2 123 456 7890")
test_number("Invalid: Area code starts with 0", "(023) 456-7890")
test_number("Invalid: Area code starts with 1", "(123) 456-7890")
test_number("Invalid: Exchange code starts with 0", "(223) 056-7890")
test_number("Invalid: Exchange code starts with 1", "(223) 156-7890")

Now, run it from your terminal:


crystal run runner.cr

You should see a clean output demonstrating how the struct correctly identifies valid and invalid numbers.

Detailed Code Walkthrough

Let's dissect the logic step-by-step to understand exactly how it works.

ASCII Diagram: The Validation Logic Flow

This diagram illustrates the decision-making process inside our `initialize` method.

    ● Raw String Input
    │ e.g., "1 (415) 555-2671"
    ▼
  ┌──────────────────┐
  │ Sanitize String  │
  │ (Keep only digits) │
  └────────┬─────────┘
           │
           ▼
    ◆ Digit Count?
   ╱       │        ╲
11 digits  10 digits  Other
  │         │           │
  ▼         │           ▼
◆ Starts   │      ┌──────────┐
│ with '1'?│      │ Set as   │
└──────────┘      │ Invalid  │
 ╱      ╲         └──────────┘
Yes      No           │
 │        │           │
 ▼        ▼           │
┌─────────┐ ┌───────┐ │
│ Strip '1' │ │ Invalid │ │
└─────────┘ └───────┘ │
    │         │         │
    └─────────┼─────────┘
              ▼
  ┌──────────────────┐
  │ Validate Content │
  │ (Area/Exch Code) │
  └────────┬─────────┘
           │
           ▼
    ◆ Is Content Valid?
   ╱                  ╲
 Yes                   No
  │                     │
  ▼                     ▼
┌───────────┐      ┌───────────┐
│ Store Clean │      │ Store Invalid │
│ 10-digit #  │      │ Marker      │
└───────────┘      └───────────┘
     │                    │
     └──────────┬─────────┘
                ▼
           ● Final State

1. Initialization and Sanitization

def initialize(raw_phrase : String)
  digits = raw_phrase.scan(/\d/).join
  # ...
end

The process begins in the initialize method. The very first step is to clean the input string. We use String#scan(/\d/). The regular expression /\d/ matches any digit character. scan returns an array of all matches. For an input like "(223) 456-7890", this produces ["2", "2", "3", "4", "5", "6", "7", "8", "9", "0"]. We then call join on this array to concatenate the elements into a single string: "2234567890".

2. Length-Based Validation

@cleaned_number = case digits.size
when 10
  validate_content(digits)
when 11
  if digits.starts_with?('1')
    validate_content(digits[1..-1])
  else
    INVALID_NUMBER
  end
else
  INVALID_NUMBER
end

Next, we use a case statement to check the length of our sanitized digits string. This is the most efficient way to handle the primary NANP rules:

  • 10 digits: This is the ideal length. We pass the string directly to our private helper method, validate_content, for the next stage of checks.
  • 11 digits: This is only valid if the first digit is the country code 1. We check this with digits.starts_with?('1'). If true, we slice the string to remove the first character (digits[1..-1]) and pass the resulting 10-digit string to validate_content. If it starts with any other digit, it's invalid.
  • Any other length: All other lengths (e.g., 9 or 12 digits) are immediately invalid. We assign our constant INVALID_NUMBER to the instance variable @cleaned_number.

3. Content-Based Validation

private def validate_content(ten_digit_string : String)
  area_code_valid = !{'0', '1'}.includes?(ten_digit_string[0])
  exchange_code_valid = !{'0', '1'}.includes?(ten_digit_string[3])

  if area_code_valid && exchange_code_valid
    ten_digit_string
  else
    INVALID_NUMBER
  end
end

This private method performs the final, crucial checks. It receives a guaranteed 10-digit string.

  • ten_digit_string[0] accesses the first character (the first digit of the area code). We check if it's '0' or '1'. The !{...}.includes?() is a concise way to check for non-membership.
  • ten_digit_string[3] accesses the fourth character (the first digit of the exchange code) and performs the same check.
  • If both conditions pass (area_code_valid && exchange_code_valid), the number is fully valid, and we return the 10-digit string. Otherwise, we return the INVALID_NUMBER constant.

4. Public Interface Methods

def number : String
  @cleaned_number
end

def area_code : String
  @cleaned_number[0, 3]
end

def to_s(io : IO)
  io << "(" << area_code << ") " << @cleaned_number[3, 3] << "-" << @cleaned_number[6, 4]
end

These methods provide a clean API for interacting with a PhoneNumber object. The number and area_code methods are simple accessors. The to_s method is particularly idiomatic in Crystal. It takes an IO object as an argument and appends its formatted representation to it, which is more memory-efficient than creating and returning new strings, especially when building large strings.


Alternative Approaches and Considerations

While our solution is robust and idiomatic, it's worth considering other ways to tackle the problem and their trade-offs.

Pros & Cons: Regex vs. String Methods

Approach Pros Cons
Pure String Methods (Our Solution) - Highly readable and explicit.
- Often faster for simple checks.
- Easy to debug step-by-step.
- Can become verbose for more complex patterns.
- Requires more lines of code.
Complex Single Regex - Extremely concise; can perform validation in one line.
- Powerful for complex pattern matching.
- Can be difficult to read and maintain ("write-only code").
- Potentially slower due to regex engine overhead.
- Harder to provide specific error messages (e.g., "invalid area code" vs. "wrong length").

A single, complex regex to solve this might look something like this:


# Caution: This is complex and less readable
VALID_NANP_REGEX = /^(?:\+?1)?\s*\(?([2-9]\d{2})\)?[\s.-]*([2-9]\d{2})[\s.-]*(\d{4})$/

def validate_with_single_regex(raw_phrase)
  match = raw_phrase.match(VALID_NANP_REGEX)
  if match
    # Reconstruct the number from capture groups
    "#{match[1]}#{match[2]}#{match[3]}"
  else
    "0000000000"
  end
end

While this regex works, it's significantly harder to understand at a glance compared to our step-by-step procedural approach. For this specific kodikra module, the clarity and maintainability of the string manipulation approach make it the superior choice.

Future-Proofing and Extensibility

Our current solution is tailored specifically for NANP. How could we extend it?

  • International Numbers: To support other country codes, the logic would need to be expanded. A dictionary or hash map could store country codes and their corresponding length/format rules. The initial parsing would need to identify the country code first.
  • Configuration: The rules (e.g., invalid starting digits) could be extracted into constants or a configuration object, making the validator adaptable to different numbering plans without changing the core logic.

As of now, the Crystal ecosystem is growing. In the next 1-2 years, we might see the emergence of mature, dedicated libraries for phone number parsing (like Google's libphonenumber ported to Crystal), which would become the standard way to handle this. However, understanding how to build such a validator from scratch is a fundamental skill.


Frequently Asked Questions (FAQ)

What exactly is the North American Numbering Plan (NANP)?

The NANP is a telephone numbering system for the Public Switched Telephone Network in the United States, Canada, Bermuda, and 17 Caribbean nations. It uses a 10-digit format, comprising a 3-digit area code and a 7-digit local number, and shares the international country code `1`.

Why can't an area code or exchange code start with '0' or '1'?

These rules are historical. `0` was traditionally used to signal the operator, and `1` was used to signal a long-distance call. Although technology has changed, these conventions are maintained in the numbering plan to prevent ambiguity and ensure compatibility with older infrastructure.

How does Crystal's static typing help in this task?

Static typing, combined with Crystal's type inference, ensures that our variables and method returns are of the expected type (e.g., `String`). This catches a whole class of errors at compile time. For example, the compiler guarantees that `raw_phrase` in our `initialize` method will always be a `String`, preventing runtime errors if `nil` or another type were passed by mistake.

Is using a regular expression the only way to extract digits?

No. While `scan(/\d/).join` is very concise, you could also achieve this by iterating through the string's characters and building a new string. For example: `raw_phrase.chars.select(&.ascii_digit?).join`. The regex approach is often considered more idiomatic and efficient for this specific task.

What should I do if the input string contains letters?

Our current solution handles this perfectly. The initial sanitization step, `raw_phrase.scan(/\d/).join`, effectively filters out any non-digit character, including letters, punctuation, and whitespace. An input like `"My number is 415-555-1234"` will correctly be sanitized to `"4155551234"` before validation proceeds.

How could this logic be extended for international phone numbers?

To support international numbers, you would need a more sophisticated system. The first step would be to identify the country code (which can be 1 to 3 digits). You would likely need a library or a comprehensive dataset that maps country codes to their specific validation rules (e.g., number length, internal formatting). The logic would branch based on the identified country code.

Why return "0000000000" for invalid numbers instead of raising an error?

This is a design choice based on the requirements of this specific kodikra module. Returning a "null object" or a sentinel value like `"0000000000"` allows the calling code to handle invalid inputs gracefully without needing a `begin...rescue` block. In other applications, raising a custom `ArgumentError` or `InvalidPhoneNumberError` might be a more appropriate strategy to fail fast.


Conclusion: From Chaos to Clarity

We have successfully constructed a robust, efficient, and readable phone number validator in Crystal. By breaking the problem down—sanitizing the input, validating by length, and then validating by content—we created a solution that is both easy to understand and maintain. This exercise from the kodikra Crystal Learning Roadmap highlights how Crystal's expressive syntax and powerful standard library make it an excellent choice for data processing and validation tasks.

The key takeaways are clear: always validate user input, understand the rules of your data domain (like NANP), and choose tools and approaches that favor clarity and maintainability. The structured, type-safe nature of Crystal gives you the confidence to build reliable systems that can gracefully handle the messy reality of real-world data.

To continue your journey, you can discover more about the Crystal language and explore other challenging modules on our platform.

Disclaimer: The code in this article is written and tested for Crystal version 1.12+ and is designed to be forward-compatible. Syntax and standard library features may evolve in future versions of the language.


Published by Kodikra — Your trusted Crystal learning resource.