Phone Number Problem - Exercism Elixir Solution & Tutorial


Percy Grunwald's Profile Picture

Written by Percy Grunwald

— Last Updated April 1, 2024

This was a very practical Exercism exercise in which we had to validate and standardize a phone number. The problem was limited to North American phone numbers.

Live solution video

This is my daily live stream video solution to this problem.

Explanation of the solution

Stripping formatting characters

In order to validate the phone number, we first need to remove any (valid) formatting characters. The valid formatting characters are plus, parentheses, hyphens, spaces and dots as demonstrated by the examples in the README:

- `+1 (613)-995-0253`
- `613-995-0253`
- `1 613 995 0253`
- `613.995.0253`

At first, I thought it would be a good idea to simply remove all the non-digit characters with something like String.replace(~r/\D/, ""), but that caused the following test to fail:

test "invalid when proper number of digits but letters mixed in" do
  assert Phone.number("2a1a2a5a5a5a0a1a0a0a") == "0000000000"
end

Better to remove valid formatting characters with String.replace/4 explicitly:

defp strip_formatting_characters(raw) do
  raw
  |> String.replace(~r/[\+\(\)\-\ \.]/, "")
end

Validating the string after formatting characters have been stripped

We need to validate the resulting string after all the formatting characters have been stripped. The validity is determined by the following rules:

  1. The string may start with a 1 if the phone number contains the country code
  2. Immediately after the country code is the area code, which must follow the rule NXX where N is a number 2-9 and X is a number 0-9
  3. Immediately after the area code is the exchange code, which has the same format as the area code NXX
  4. Immediately after the exchange code is the subscriber number, which has the format XXXX

These rules can be expressed nicely in a regex and checked with String.match?/2:

defp is_number_string_valid?(number_string) do
  String.match?(number_string, ~r/^(1|)[2-9]\d{2}[2-9]\d{6}$/)
end

You can play around with this regex pattern on regexr.com.

A quick explanation of the regex pattern:

  • ^ matches the start of the string
  • (1|) matches a 1 or nothing (country code included or not included)
  • [2-9]\d{2} matches a single digit from 2 to 9, then two digits of any value (area code NXX)
  • [2-9]\d{6} matches a single digit from 2 to 9, then six digits of any value (exchange code NXX + subscriber number XXXX = NXXXXXX)
  • $ matches the end of the string

Extracting a valid phone number without country code, or returning the default number

Once we have our phone number stripped of formatting characters we can simply validate the remaining string with the validation function created in the previous section. Depending on the validity of the string, we need to return the phone number excluding the country code, or a default “error number” "0000000000".

If the number is valid, to get the 10-digit number excluding the country we can simply use String.slice/2 to extract the last 10 characters in the valid string:

@default_number_string "0000000000"

def number(raw) do
  number_string = strip_formatting_characters(raw)

  if is_number_string_valid?(number_string) do
    number_string
    |> String.slice(-10..-1)
  else
    @default_number_string
  end
end

Extract the area code from the valid phone number without formatting characters

The area_code/1 function should return just the area code if we pass in a raw number. We can simply leverage the number/1 we created previously, which will strip the formatting and return the number without the country code. From there, we can simply extract the first 3 characters of the string with String.slice/2:

@spec area_code(String.t()) :: String.t()
def area_code(raw) do
  raw
  |> number()
  |> String.slice(0..2)
end

Extract the exchange code and subscriber number from the valid phone number

The following helper functions are necessary for pretty printing a phone number in a standard way. The pattern is the same as the area code: pass the raw number to the number/1 function and then extract a certain portion of the string with String.slice/2:

defp get_exchange_code(raw) do
  raw
  |> number()
  |> String.slice(3..5)
end
defp get_subscriber_number(raw) do
  raw
  |> number()
  |> String.slice(6..9)
end

Pretty print the phone number in a standard form

Finally, we need to implement the pretty/1 function that takes a phone number in a raw format and prints the standard form of the phone number like "12125550100" => "(212) 555-0100":

@spec pretty(String.t()) :: String.t()
def pretty(raw) do
  area_code_string = area_code(raw)
  exchange_code_string = get_exchange_code(raw)
  subscriber_number_string = get_subscriber_number(raw)

  "(#{area_code_string}) #{exchange_code_string}-#{subscriber_number_string}"
end

Full solution in text form

Here’s the full solution in text form, in case you want to look over the final product (I’ve removed the comments for brevity):

defmodule Phone do
  @default_number_string "0000000000"

  @spec number(String.t()) :: String.t()
  def number(raw) do
    number_string = strip_formatting_characters(raw)

    if is_number_string_valid?(number_string) do
      number_string
      |> String.slice(-10..-1)
    else
      @default_number_string
    end
  end

  defp is_number_string_valid?(number_string) do
    String.match?(number_string, ~r/^(1|)[2-9]\d{2}[2-9]\d{6}$/)
  end

  defp strip_formatting_characters(raw) do
    raw
    |> String.replace(~r/[\+\(\)\-\ \.]/, "")
  end

  @spec area_code(String.t()) :: String.t()
  def area_code(raw) do
    raw
    |> number()
    |> String.slice(0..2)
  end

  @spec pretty(String.t()) :: String.t()
  def pretty(raw) do
    area_code_string = area_code(raw)
    exchange_code_string = get_exchange_code(raw)
    subscriber_number_string = get_subscriber_number(raw)

    "(#{area_code_string}) #{exchange_code_string}-#{subscriber_number_string}"
  end

  defp get_exchange_code(raw) do
    raw
    |> number()
    |> String.slice(3..5)
  end

  defp get_subscriber_number(raw) do
    raw
    |> number()
    |> String.slice(6..9)
  end
end

Comment & Share