Table of Contents
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.
- Video available here: https://youtu.be/YdL-KS70E3g
- Also streaming on Twitch: https://www.twitch.tv/percygrunwald
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:
- The string may start with a
1
if the phone number contains the country code - Immediately after the country code is the area code, which must follow the rule
NXX
whereN
is a number 2-9 andX
is a number 0-9 - Immediately after the area code is the exchange code, which has the same format as the area code
NXX
- 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 a1
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 codeNXX
)[2-9]\d{6}
matches a single digit from 2 to 9, then six digits of any value (exchange codeNXX
+ subscriber numberXXXX
=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