-
Notifications
You must be signed in to change notification settings - Fork 3
feat: add isValidPhoneNumber and formatPhoneNumberOrThrow to phone-number #210
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
export * from "./lib/formatPhoneNumber"; | ||
export * from "./lib/isValidPhoneNumber"; |
153 changes: 69 additions & 84 deletions
153
packages/phone-number/src/lib/formatPhoneNumber.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,102 +1,87 @@ | ||
import { expectToBeFailure, expectToBeSuccess } from "@clipboard-health/testing-core"; | ||
|
||
import { formatPhoneNumber } from "./formatPhoneNumber"; | ||
|
||
describe("formatPhoneNumber", () => { | ||
describe.each([ | ||
import { formatPhoneNumber, formatPhoneNumberOrThrow } from "./formatPhoneNumber"; | ||
|
||
const TEST_CASES = { | ||
valid: [ | ||
{ phoneNumber: "(555) 123-4567", name: "US phone number with parentheses" }, | ||
{ phoneNumber: "5551234567", name: "US phone number without special characters" }, | ||
{ phoneNumber: "1-555-123-4567", name: "phone number with country code" }, | ||
{ phoneNumber: "+44 20 7946 0958", name: "international phone number" }, | ||
{ phoneNumber: "12", name: "very short phone number (however impractical)" }, | ||
{ | ||
format: "E.164" as const, | ||
expectedResults: { | ||
usPhoneWithParens: "+15551234567", | ||
usPhoneWithoutSpecialChars: "+15551234567", | ||
usPhoneWithCountryCode: "+15551234567", | ||
internationalPhone: "+442079460958", | ||
shortPhone: "+112", | ||
phoneWithExtension: "+15551234567", | ||
}, | ||
phoneNumber: "(555) 123-4567 ext 123", | ||
name: "phone numbers with extensions by ignoring extension", | ||
}, | ||
], | ||
invalid: [ | ||
{ phoneNumber: "invalid", name: "invalid phone number", expectedError: "NOT_A_NUMBER" }, | ||
{ phoneNumber: "", name: "empty string", expectedError: "NOT_A_NUMBER" }, | ||
{ | ||
format: "humanReadable" as const, | ||
expectedResults: { | ||
usPhoneWithParens: "(555) 123-4567", | ||
usPhoneWithoutSpecialChars: "(555) 123-4567", | ||
usPhoneWithCountryCode: "(555) 123-4567", | ||
internationalPhone: "020 7946 0958", | ||
shortPhone: "12", | ||
phoneWithExtension: "(555) 123-4567 ext. 123", | ||
}, | ||
phoneNumber: "123456789012345678901234567890", | ||
name: "too long phone number", | ||
expectedError: "TOO_LONG", | ||
}, | ||
])("with format $format", ({ format, expectedResults }) => { | ||
it("should format valid US phone number with parentheses", () => { | ||
const result = formatPhoneNumber({ phoneNumber: "(555) 123-4567", format }); | ||
|
||
expectToBeSuccess(result); | ||
expect(result.value).toBe(expectedResults.usPhoneWithParens); | ||
}); | ||
|
||
it("should format valid US phone number without special characters", () => { | ||
const result = formatPhoneNumber({ phoneNumber: "5551234567", format }); | ||
|
||
expectToBeSuccess(result); | ||
expect(result.value).toBe(expectedResults.usPhoneWithoutSpecialChars); | ||
}); | ||
|
||
it("should format valid phone number with country code", () => { | ||
const result = formatPhoneNumber({ phoneNumber: "1-555-123-4567", format }); | ||
|
||
expectToBeSuccess(result); | ||
expect(result.value).toBe(expectedResults.usPhoneWithCountryCode); | ||
}); | ||
|
||
it("should format valid international phone number", () => { | ||
const result = formatPhoneNumber({ phoneNumber: "+44 20 7946 0958", format }); | ||
], | ||
} as const; | ||
|
||
const EXPECTED_RESULTS = { | ||
"E.164": { | ||
"(555) 123-4567": "+15551234567", | ||
"5551234567": "+15551234567", | ||
"1-555-123-4567": "+15551234567", | ||
"+44 20 7946 0958": "+442079460958", | ||
"12": "+112", | ||
"(555) 123-4567 ext 123": "+15551234567", | ||
}, | ||
humanReadable: { | ||
"(555) 123-4567": "(555) 123-4567", | ||
"5551234567": "(555) 123-4567", | ||
"1-555-123-4567": "(555) 123-4567", | ||
"+44 20 7946 0958": "020 7946 0958", | ||
"12": "12", | ||
"(555) 123-4567 ext 123": "(555) 123-4567 ext. 123", | ||
}, | ||
} as const; | ||
|
||
expectToBeSuccess(result); | ||
expect(result.value).toBe(expectedResults.internationalPhone); | ||
}); | ||
|
||
it("should format very short phone number (however impractical)", () => { | ||
const result = formatPhoneNumber({ phoneNumber: "12", format }); | ||
|
||
expectToBeSuccess(result); | ||
expect(result.value).toBe(expectedResults.shortPhone); | ||
}); | ||
|
||
it("should format phone numbers with extensions by ignoring extension", () => { | ||
const result = formatPhoneNumber({ phoneNumber: "(555) 123-4567 ext 123", format }); | ||
describe("formatPhoneNumber", () => { | ||
describe.each(["E.164", "humanReadable"] as const)("with format %s", (format) => { | ||
it.each(TEST_CASES.valid)("should format valid $name", ({ phoneNumber }) => { | ||
const result = formatPhoneNumber({ phoneNumber, format }); | ||
|
||
expectToBeSuccess(result); | ||
expect(result.value).toBe(expectedResults.phoneWithExtension); | ||
expect(result.value).toBe(EXPECTED_RESULTS[format][phoneNumber]); | ||
}); | ||
|
||
it("should return error for invalid phone number", () => { | ||
const result = formatPhoneNumber({ phoneNumber: "invalid", format }); | ||
it.each(TEST_CASES.invalid)( | ||
"should return error for $name", | ||
({ phoneNumber, expectedError }) => { | ||
const result = formatPhoneNumber({ phoneNumber, format }); | ||
|
||
expectToBeFailure(result); | ||
expect(result.error.issues).toHaveLength(1); | ||
expect(result.error.issues[0]!.message).toBe("Invalid phone number"); | ||
expect(result.error.issues[0]!.code).toBe("INVALID_PHONE_NUMBER"); | ||
}); | ||
expectToBeFailure(result); | ||
expect(result.error.issues).toHaveLength(1); | ||
expect(result.error.issues[0]!.message).toBe(expectedError); | ||
expect(result.error.issues[0]!.code).toBe("INVALID_PHONE_NUMBER"); | ||
}, | ||
); | ||
}); | ||
}); | ||
|
||
it("should return error for empty string", () => { | ||
const result = formatPhoneNumber({ phoneNumber: "", format }); | ||
describe("formatPhoneNumberOrThrow", () => { | ||
describe.each(["E.164", "humanReadable"] as const)("with format %s", (format) => { | ||
it.each(TEST_CASES.valid)("should format valid $name", ({ phoneNumber }) => { | ||
const actual = formatPhoneNumberOrThrow({ phoneNumber, format }); | ||
|
||
expectToBeFailure(result); | ||
expect(result.error.issues).toHaveLength(1); | ||
expect(result.error.issues[0]!.message).toBe("Invalid phone number"); | ||
expect(result.error.issues[0]!.code).toBe("INVALID_PHONE_NUMBER"); | ||
expect(actual).toBe(EXPECTED_RESULTS[format][phoneNumber]); | ||
}); | ||
|
||
it("should return error for too long phone number", () => { | ||
const result = formatPhoneNumber({ | ||
phoneNumber: "123456789012345678901234567890", | ||
format, | ||
}); | ||
|
||
expectToBeFailure(result); | ||
expect(result.error.issues).toHaveLength(1); | ||
expect(result.error.issues[0]!.message).toBe("Invalid phone number"); | ||
expect(result.error.issues[0]!.code).toBe("INVALID_PHONE_NUMBER"); | ||
}); | ||
it.each(TEST_CASES.invalid)( | ||
"should throw error for $name", | ||
({ phoneNumber, expectedError }) => { | ||
expect(() => { | ||
formatPhoneNumberOrThrow({ phoneNumber, format }); | ||
}).toThrow(expectedError); | ||
}, | ||
); | ||
}); | ||
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,18 +1,75 @@ | ||
import { failure, type ServiceResult, success } from "@clipboard-health/util-ts"; | ||
import { | ||
failure, | ||
isFailure, | ||
type ServiceResult, | ||
success, | ||
toError, | ||
} from "@clipboard-health/util-ts"; | ||
import { parsePhoneNumberWithError } from "libphonenumber-js"; | ||
|
||
export function formatPhoneNumber(params: { | ||
phoneNumber: string; | ||
import { type WithPhoneNumber } from "./types"; | ||
|
||
export interface FormatPhoneNumberParams extends WithPhoneNumber { | ||
format: "E.164" | "humanReadable"; | ||
}): ServiceResult<string> { | ||
} | ||
|
||
/** | ||
* Formats a phone number to the specified format. | ||
* | ||
* @param params - The formatting parameters | ||
* @param params.phoneNumber - The phone number to format | ||
* @param params.format - The desired output format ("E.164" for international format or "humanReadable" for national format) | ||
* @returns A ServiceResult containing the formatted phone number or an error | ||
* | ||
* @example | ||
* ```ts | ||
* const result = formatPhoneNumber({ phoneNumber: "(555) 123-4567", format: "E.164" }); | ||
* if (isSuccess(result)) { | ||
* console.log(result.value); // "+15551234567" | ||
* } | ||
* ``` | ||
*/ | ||
export function formatPhoneNumber(params: FormatPhoneNumberParams): ServiceResult<string> { | ||
const { phoneNumber, format } = params; | ||
|
||
try { | ||
const parsedPhoneNumber = parsePhoneNumberWithError(phoneNumber.trim(), { | ||
defaultCountry: "US", | ||
}); | ||
return success(parsedPhoneNumber.format(format === "E.164" ? "E.164" : "NATIONAL")); | ||
} catch { | ||
return failure({ issues: [{ message: "Invalid phone number", code: "INVALID_PHONE_NUMBER" }] }); | ||
} catch (error) { | ||
return failure({ issues: [{ message: toError(error).message, code: "INVALID_PHONE_NUMBER" }] }); | ||
} | ||
} | ||
|
||
/** | ||
* Formats a phone number to the specified format, throwing an error if formatting fails. | ||
* | ||
* This is a convenience function that wraps `formatPhoneNumber` and throws an error | ||
* instead of returning a ServiceResult. Use this when you want to handle errors via | ||
* exception handling rather than explicit error checking. | ||
* | ||
* @param params - The formatting parameters | ||
* @param params.phoneNumber - The phone number to format | ||
* @param params.format - The desired output format ("E.164" for international format or "humanReadable" for national format) | ||
* @returns The formatted phone number | ||
* @throws Error when the phone number cannot be formatted (invalid format, missing country code, etc.) | ||
* | ||
* @example | ||
* ```ts | ||
* try { | ||
* const formatted = formatPhoneNumberOrThrow({ phoneNumber: "(555) 123-4567", format: "E.164" }); | ||
* console.log(formatted); // "+15551234567" | ||
* } catch (error) { | ||
* console.error("Invalid phone number:", error.message); | ||
* } | ||
* ``` | ||
*/ | ||
export function formatPhoneNumberOrThrow(params: FormatPhoneNumberParams): string { | ||
const result = formatPhoneNumber(params); | ||
if (isFailure(result)) { | ||
throw result.error; | ||
} | ||
|
||
return result.value; | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
import { isValidPhoneNumber } from "./isValidPhoneNumber"; | ||
|
||
const TEST_CASES = { | ||
valid: [ | ||
{ phoneNumber: "(212) 555-1234", name: "US phone number with parentheses" }, | ||
{ phoneNumber: "2125551234", name: "US phone number without special characters" }, | ||
{ phoneNumber: "1-212-555-1234", name: "phone number with country code" }, | ||
{ phoneNumber: "+1 212-555-1234", name: "phone number with +1 country code" }, | ||
{ phoneNumber: " (212) 555-1234 ", name: "phone number with leading/trailing whitespace" }, | ||
{ phoneNumber: "+44 20 7946 0958", name: "international phone number" }, | ||
], | ||
invalid: [ | ||
{ phoneNumber: "invalid", name: "invalid phone number" }, | ||
{ phoneNumber: "", name: "empty string" }, | ||
{ phoneNumber: " ", name: "whitespace only" }, | ||
{ phoneNumber: "123", name: "too short phone number" }, | ||
{ phoneNumber: "123456789012345678901234567890", name: "too long phone number" }, | ||
{ phoneNumber: "abc-def-ghi", name: "alphabetic characters" }, | ||
], | ||
} as const; | ||
|
||
describe("isValidPhoneNumber", () => { | ||
it.each(TEST_CASES.valid)("should return true for $name", ({ phoneNumber }) => { | ||
const actual = isValidPhoneNumber({ phoneNumber }); | ||
|
||
expect(actual).toBe(true); | ||
}); | ||
|
||
it.each(TEST_CASES.invalid)("should return false for $name", ({ phoneNumber }) => { | ||
const actual = isValidPhoneNumber({ phoneNumber }); | ||
|
||
expect(actual).toBe(false); | ||
}); | ||
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import { isValidPhoneNumber as isValidPhoneNumberF } from "libphonenumber-js"; | ||
|
||
import { type WithPhoneNumber } from "./types"; | ||
|
||
export type IsValidPhoneNumberParams = WithPhoneNumber; | ||
|
||
export function isValidPhoneNumber(params: IsValidPhoneNumberParams): boolean { | ||
const { phoneNumber } = params; | ||
|
||
return isValidPhoneNumberF(phoneNumber.trim(), { | ||
defaultCountry: "US", | ||
}); | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export interface WithPhoneNumber { | ||
phoneNumber: string; | ||
} |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.