Validate phone numbers with Zod
Phone number validation in Zod that knows the national numbering plans: what people type is read in the plans you pick, a number no plan accepts gets Zod's usual error, and what you store is its E.164 form. The reading is done by one file with no dependency, from the JavaScript guide; the rest is Zod's own validation hook, a few lines long.
1. The file
Take phone.mjs from the JavaScript guide, with the plans of the countries you serve (the link opens it for the United Kingdom, the United States and France, the plans the tests below use). It needs Node 20 or later, or any current browser; no dependency. Its parse reads a number as typed and returns the number with its E.164 form, region and line type, or null when no plan accepts it.
npm install zod2. The Zod side
phone-schema.mjs
The schema: the language guide's parse, inside Zod.
import { z } from 'zod'
import { parse } from './phone.mjs'
/**
* A phone number as people type it, read in the plans of phone.mjs; the output is its E.164 form.
* defaultRegion (an ISO code such as 'GB') reads numbers typed without their country code.
*/
export const phoneNumber = (defaultRegion) =>
z.string()
.trim()
.refine((text) => parse(text, defaultRegion) !== null, 'Not a valid phone number')
.transform((text) => parse(text, defaultRegion).e164)
export const Signup = z.object({
name: z.string().min(1),
phone: phoneNumber('GB'),
})3. The tests
phone-schema.test.mjs: The tests, with node:test.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { Signup, phoneNumber } from './phone-schema.mjs'
test('reads a national number in the default region and outputs E.164', () => {
assert.deepEqual(Signup.parse({ name: 'Ada', phone: '07400 123456' }), { name: 'Ada', phone: '+447400123456' })
})
test('reads an international number whatever the default region', () => {
assert.equal(phoneNumber('GB').parse('+1 201-555-0123'), '+12015550123')
})
test('refuses what no plan accepts, on the phone field', () => {
const result = Signup.safeParse({ name: 'Ada', phone: '+44 7400 12345' })
assert.equal(result.success, false)
assert.equal(result.error.issues[0].message, 'Not a valid phone number')
assert.deepEqual(result.error.issues[0].path, ['phone'])
})node --testRun before publishing with zod 4.6.7 and the file generated for GB, US, FR, as 2026-10-25.
What is specific to Zod
refinethentransform: the refinement makes the field fail with a message, the transform replaces the text with its E.164 form, so the parsed object holds what you store. The number is read twice, which costs microseconds.- In TypeScript the same code types itself:
z.infer<typeof Signup>is{ name: string; phone: string }, andphoneis E.164 after parsing. - The same schema validates in the browser and on the server: phone.mjs has no dependency. Keep the server's check: a browser can be skipped.