Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/phone-number/src/index.ts
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 packages/phone-number/src/lib/formatPhoneNumber.spec.ts
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);
},
);
});
});
69 changes: 63 additions & 6 deletions packages/phone-number/src/lib/formatPhoneNumber.ts
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;
}
34 changes: 34 additions & 0 deletions packages/phone-number/src/lib/isValidPhoneNumber.spec.ts
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);
});
});
13 changes: 13 additions & 0 deletions packages/phone-number/src/lib/isValidPhoneNumber.ts
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",
});
}
3 changes: 3 additions & 0 deletions packages/phone-number/src/lib/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface WithPhoneNumber {
phoneNumber: string;
}