Skip to content

End-to-end type-safety with JSON Schema

End-to-end type-safety with JSON Schema

End-to-end type-safety with JSON Schema

I recently wrote an introduction to JSON Schema post. If you’re unfamiliar with it, check out the post, but TLDR: It’s a schema specification that can be used to define the input and output data for your JSON API. In my post, I highlight many fantastic benefits you can reap from defining schemas for your JSON API. One of the more interesting things you can achieve with your schemas is end-to-end type safety from your backend API to your client application(s). In this post, we will explore how this can be accomplished slightly deeper.

Overview

The basic idea of what we want to achieve is:

  • a JSON API server that validates input and output data using JSON Schema
  • The JSON Schema definitions that our API uses transformed into TypeScript types

With those pieces in place, we can achieve type safety on our API server and the consuming client application. The server side is pretty straightforward if you’re using a server like Fastify with already enabled JSON Schema support. This post will focus on the concepts more than the actual implementation details though.

Here’s a simple diagram illustrating the high-level concept: diagram illustrating the high-level concept

We can share the schema and type declaration between the client and server. In that case, we can make a request to an endpoint where we know its type and schema, and assuming the server validates the data against the schema before sending it back to the client, our client can be confident about the type of the response data.

Marrying JSON Schema and TypeScript

There are a couple of different ways to accomplish this:

  • Generating types from schema definitions using code generation tools
  • Creating TypeBox definitions that can infer TypeScript types and be compiled to JSON Schema

I recommend considering both and figuring out which would better fit your application and workflows. Like anything else, each has its own set of trade-offs. In my experience, I’ve found TypeBox to be the most compelling if you want to go deep with this pattern.

Code generation

A couple of different packages are available for generating TS types from JSON Schema definitions.

They are CLI tools that you can provide a glob path to where your schema files are located and will generate TS declaration files to a specified output path. You can set up an npm hook or a similar type of script that will generate types for your development environment.

TypeBox

TypeBox is a JSON Schema type builder. With this approach, instead of json files, we define schemas in code using the TypeBox API. The TypeBox definitions infer to TypeScript types directly, which eliminates the code generation step described above.

Here’s a simple example from the documentation of a JSON Schema definition declared with TypeBox:

const T = Type.Object({
  id: Type.String(),
  name: Type.String(),
  timestamp: Type.Integer()
});
// Converts to this JSON Schema
{
  "type": "object",
  "properties": {
    "id": {
      "type": "string"
    },
    "name": {
      "type": "string"
    },
    "timestamp": {
      "type": "integer"
    }
  },
  "required": ["id", "name", "timestamp"]
}

This can then be inferred as a TypeScript type:

type T = Static<typeof T>      
// Generates this type
type T = {
	id: string,
	name: string,
	timestamp: number
}

Aside from schemas and types, TypeBox can do a lot more to help us on our type-safety journey. We will explore it a bit more in upcoming sections.

Sharing schemas between client and server applications

Sharing our JSON Schema between our server and client app is the main requirement for end-to-end type-safety.

There are a couple of different ways to accomplish this, but the simplest would be to set up our codebase as a monorepo that contains both the server and client app. Some popular options for TypeScript monorepos are: PNPM, Turborepo, and NX.

If a monorepo is not an option, you can publish your schema and types as a package that can be installed in both projects. However, this setup would require a lot more maintenance work.

Ultimately, as long as you can import your schemas and types from the client and server app, you are in good shape.

Server-to-client validation and type-safety

For the sake of simplicity, let's focus on data flowing from the server to the client for now. Generally speaking, the concepts also apply in reverse, as long as your JSON API server validates your inputs and outputs. We’ll look at the most basic version of having strongly typed data on the client from a request to our server.

Type-safe client requests

In our server application, if we validate the /users endpoint with a shared schema - on the client side, when we make the request to the endpoint, we know that the response data is validated using the user schema. As long as we are confident of this fact, we can use the generated type from that schema as the return type on our client fetch call.

Here’s some pseudocode:

import type { UserList } from './schemas'

async function getUsers() {
	return fetch<UserList>('/users').then((res) => res.json())
}

Our server endpoint would look something like this:

fastify.get('/users', { schema: UserListSchema }, async (request, reply) => {
	// Fetch users from your data source here
	const users = await fetchUsersFromDataSource();
	return users;
});

You could get creative and build out a map that defines all of your endpoints, their metadata, and schemas, and use the map to define your server endpoints and create an API client.

Transforming data over the wire

Everything looks stellar, but we can still take our efforts a bit further. To this point, we are still limited to serialized JSON data. If we have a created_at field (number or ISO string) tied to our user, and we want it to be a Date object when we get a hold of it on the client side - additional work and consideration are required.

There are some different strategies out there for deserializing JSON data. The great thing about having shared schemas between our client and server is that we can encode our type information in the schema without sending additional metadata from our server to the client.

Using format to declare type data

In my initial JSON Schema blog post, I touched on the format field of the specification. In our schema, if the actual type of our date is a string in ISO8601 format, we can declare our format to be "date-time". We can use this information on the client to transform the field into a proper Date object.

{
  "properties": {
    "created_at": {
      "type": "string",
      "format": "date-time"
    }
  }
}

Transforming serialized JSON Data

This can be a little bit tricky; again, there are many ways to accomplish it. To demonstrate the concept, we’ll use TypeBox to define our schemas as discussed above.

TypeBox provides a Transform type that you can use to declare, encode, and decode methods for your schema definition.

const T = Type.Transform(Type.String())
  .Decode(value => new Date(value))                  
  .Encode(value => value.toISOString()) 

It even provides helpers to statically generate the decoded and encoded types for your schema

type D = StaticDecode<typeof T>                      // type D = Date      
type E = StaticEncode<typeof T>                      // type E = string
type T = Static<typeof T>

If you declare your decode and encode functions for your schemas, you can then use the TypeBox API to handle decoding the serialized values returned from your JSON API. Here’s what the concept looks like in practice fetching a user from our API:

import { Value } from 'type-box'
import { UserSchema } from './schemas'

type User = StaticEncode<typeof UserSchema>

async function getUser(id: string) {
	const user = await fetch<UserJson>(`/user/${id}`).then((res) => res.json())
	return Value.Decode(UserSchema, user);
}

Nice.

You could use a validation library like Zod to achieve a similar result but here we aren’t actually doing any validation on our client side. That happened on the server. We just know the types based on the schema since both ends share them. On the client, we are just transforming our serialized JSON into what we want it to be in our client application.

Summary

There are a lot of pieces in play to accomplish end-to-end type safety. With the help of JSON Schema and TypeBox though, it feels like light work for a semi-roll-your-own type of solution. Another great thing about it is that it’s flexible and based on pretty core concepts like a JSON API paired with a TypeScript-based client application.

The number of benefits that you can reap from defining JSON Schemas for your APIs is really great. If you’re like me and wanna keep it simple by avoiding GraphQL or other similar tools, this is a great approach.