Skip to main content
Image de couverture pour TypeScript and Zod to validate your data structures

TypeScript and Zod to validate your data structures

For several years now, TypeScript has become an essential language when you want to produce quality JavaScript code. Beyond the language itself, which keeps evolving, it is mainly the static type checking system that lets developers catch potential errors early.

However, this checking quickly shows its limits because it is only performed at compile time.

No verification is done when the code runs (i.e. at runtime)…

Let’s define a User type with two properties, name and age:

type User = {
  name: string
  age: number
}

Let’s define a simple function to determine whether a user is an adult or not:

const isAdult = (user: User): boolean => {
  return user.age > 18
}

Using this type causes no problem in a controlled code context:

const john: User = { name: 'John', age: 20 }
console.log(isAdult(john)) // output: true

Using a “type assertion”, no problem either:

const john = { name: 'John', age: 20 } as User
console.log(isAdult(john)) // output: true

And what if we change the structure by omitting a property?

const john = { name: 'John' } as User
console.log(isAdult(john)) // output: false,  what ?

No error is raised, and yet the code is incorrect!

Several issues in the function:

  • no check that the age property exists
  • no check of the number type before the comparison

By using as User, we implicitly accept that the data structure may not match the one defined in the type.

Let’s take another example where the data does not come from the application code but from an external source:

// age key is missing
const json = `{"name": 2}`
const data = JSON.parse(json) as User

Or again:

async function getUser(id: string): Promise<User> {
  // data received is not verified...
  const rep = await fetch(`http://localhost:8000/users/${id}`)
  // is it a User shape ?
  const user = (await rep.json()) as User
  return user
}

We can therefore keep running our code logic with data that only partially matches our type, or doesn’t match at all…

💡 Here we also don’t check the other error cases (e.g. HTTP status code other than 20x, non-JSON response, etc).

To avoid any runtime error, we should add checks on the properties and their types.

We apply a defensive approach:

if (user?.age && typeof user.age === 'number') {
  return user.age > 18
}
throw new Error('Invalid User provided') //or any other error management

These checks can quickly become tedious when there are several properties to validate… Moreover, it is easy to forget some cases.

Schema validation lets you ensure that the structure of the received data really conforms to the expected structure.

Zod is an open-source TypeScript library used to declare and validate your schemas (i.e. data structures).

So you will define Zod “schemas” that will be used to validate your data.

Example of a Zod schema that checks the string type and that the length is between 1 (minimum) and 20 (maximum):

const StringSchema = z.string().min(1, { message: 'Too short' }).max(20, { message: 'Too long' })

Zod can validate TypeScript’s primitive types.

It is also possible to chain, combine, and extend existing schemas:

const UserSchema = z.object({
  email: z.email().min(5),
  name: z.string(),
  age: z.number().min(0).max(120).optional(),
})

const IdSchema = z.object({
  id: z.uuid(),
})
const UserWithId = UserSchema.extend(IdSchema.shape)

💡 Since Zod v4, format validators are top-level functions: you write z.email() and z.uuid() rather than z.string().email() or z.string().uuid() (now deprecated). Likewise, .merge() gives way to .extend().

Zod offers two ways to handle errors when data is invalid.

A ZodError exception is thrown when the data is invalid:

const stringSchema = z.string()

stringSchema.parse('fish') // => returns "fish"
stringSchema.parse(12) // throws error, catch it !

If you want to handle the error case without an exception, you can use safeParse and check the value of the success property:

const result = stringSchema.safeParse(12)
if (!result.success) {
  result.error.issues
  /* [
      {
        "code": "invalid_type",
        "expected": "string",
        "path": [ "name" ],
        "message": "Invalid input: expected string, received number"
      }
  ] */
}

💡 In Zod v4, the received property was removed from the issues, and the default message changed (Invalid input: expected ...).

Zod makes it easy to define your types from the schema definitions.

Example with a simple enum:

export const NobelCategorySchema = z.enum([
  'chemistry',
  'economics',
  'literature',
  'peace',
  'physics',
  'medicine',
])
export type NobelCategory = z.infer<typeof NobelCategorySchema>

If we inspect the type, it translates in TypeScript to:

type NobelCategory = 'chemistry' | 'economics' | 'literature' | 'peace' | 'physics' | 'medicine'

Another example with a more complex type:

export const LaureateSchema = z.object({
  id: z.coerce.number(),
  firstname: z.string().default('🧐 John Doe ?'),
  surname: z.string().optional(),
  motivation: z.string(),
})
export type Laureate = z.infer<typeof LaureateSchema>

This translates to:

type Laureate = {
  id: number
  firstname: string
  motivation: string
  surname?: string | undefined
}

We can validate our data thanks to Zod schema validation:

it('should not validate if invalid field', () => {
  const laureate = 'invalid'
  const result = LaureateSchema.safeParse(laureate)

  expect(result.success).toStrictEqual(false)
})

it('should validate with optional fields', () => {
  const laureate = {
    id: '55',
    motivation: 'To be known',
  }
  const result = LaureateSchema.safeParse(laureate)

  expect(result.success).toStrictEqual(true)
  if (result.success) {
    const model: Laureate = result.data
    expect(model.id).toStrictEqual(55)
    expect(model.firstname).toStrictEqual('🧐 John Doe ?')
    expect(model.surname).toStrictEqual(undefined)
    expect(model.motivation).toStrictEqual('To be known')
  }
})

More unit test examples in my Zod + TS project on Github.

Sometimes validating each property independently is not enough: you need a rule that spans several fields at once. refine lets you add a custom validation.

In the Nobel API, a prize has either a list of laureates or an overall motivation (overallMotivation), but at least one of the two must be present:

export const PrizeSchema = z
  .object({
    year: z.coerce.number().min(1900),
    category: NobelCategorySchema,
    laureates: LaureateSchema.array().optional(),
    overallMotivation: z.string().optional(),
  })
  .refine(({ laureates, overallMotivation }) => Boolean(laureates) || Boolean(overallMotivation), {
    error: 'No laureates and no overall motivation',
    path: ['laureates'],
  })

💡 In Zod v4, a refine error message is declared via the error key (no longer message, which is deprecated).

In the case of an API call, we can now be sure that the returned data will be valid:

import { Prize, PrizeResponseSchema } from './model'

export const getNobelPrizes = async (): Promise<Prize[]> => {
  const response = await fetch('https://api.nobelprize.org/v1/prize.json')
  // data received cannot be trusted at runtime
  const data: unknown = await response.json()
  // ensure data type Prize
  const result = PrizeResponseSchema.safeParse(data)
  if (!result.success) {
    throw new Error('Error parsing prizes', { cause: result.error })
  }
  return result.data.prizes
}

This way we only expose the Promise<Prize[]> type.

Code excerpt from my example TS + Zod project on Github.

TypeScript helps developers ensure type safety at compile time. However, we cannot trust data we do not control…

Structure validation (i.e. schema validation) proves indispensable when dealing with external data.

Zod’s strengths:

  • a small library with no external dependencies, it can be integrated easily into your TypeScript projects
  • very complete and developer-friendly documentation
  • type inference from the schema
  • designed for a functional approach (e.g. optional() returns a new instance, not the mutated object…)

Zod is used in the tRPC library, which lets you build “type safe” APIs. There are many usage examples with the leading APIs (e.g. Next, Express, etc).

A library worth looking at when building your next NodeJS backend server!