Skip to content

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 zod

2. 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 --test

Run before publishing with zod 4.6.7 and the file generated for GB, US, FR, as 2026-10-25.

What is specific to Zod

  • refine then transform: 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 }, and phone is 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.