Keeping Frontend and REST-Backend in Sync with the help of zod using Axum as an example backend
Diese Seite wird aktuell nicht auf Deutsch bereitgestellt.
Frontends made with TypeScript (and by extension JavaScript) have a major issue when it comes to making sure that what you expect from your Backend is actually also what you’re getting. JavaScript, as a very dynamic language, does not have a concept of typed variables.
With typed languages like Go, Rust, Java, C#, etc. you have to define what type a variable is, either by explicitly setting the initial variable, or by defining the type directly.
// Golang
name := "Waldemar"
name = 42 // does not work, name is a string, will cause a compile error
TypeScript will cause the same errors. E.g. if you try to transpile the following file:
// TypeScript
let name = "Waldemar"
name = 42
you will get a Type 'number' is not assignable to type 'string'.
error.
However, the Browser does not work with TypeScript, but uses JavaScript. JavaScript does not have those constraints.
JavaScript will not complain about something like
// JavaScript
let name = "Waldemar"
console.log(name)
name = 42
console.log(name)
Assuming your Frontend only works with controlled inputs and data - data that was typed with TypeScript - this is not an issue. However as soon as you are fetching data from an external source you are no longer working with controlled input.
With Single-Page-Applications, the Front- and Backend are decoupled systems, so there’s nothing stopping a new version of the Backend to get deployed which no longer adheres to the previous interface.
But even then, nothing stops us just telling TypeScript what the response structure looks like.
function doSomethingWithResponse(id: number) {
...
}
interface Response {
data: {name: string, id: number}[]
}
// vvvvvvvv - use telling TS that the response adheres to the Interface
const result: Response = await fetch("backend.myService.com/api/someResource").then(e => e.json())
result.data.forEach(e => doSomethingWithResponse(e.id))
This gets transpiled to JS and all type information goes away:
function doSomethingWithResponse(id) {
...
}
const result = await fetch("backend.myService.com/api/someResource").then(e => e.json())
result.data.forEach(e => doSomethingWithResponse(e.id))
Is the ID here a string? a number? an object? defined at all? JavaScript doesn’t care. It will assign whatever it gets and pass it down.
The doSomethingWithResponse
Function expects a number, but at runtime, we don’t know what it’s getting.
While JavaScript doesnt have a strict typing system, it does have ways to check the type of a variable at runtime. One could validate that the response has the correct structure before passing it down to the rest of the system.
const x: unknown = await fetch("...").then((e) => e.json());
if (typeof x !== "object" || x === null) {
throw new Error("not an object");
}
if (!("data" in x) || !Array.isArray(x.data) || x === null) {
throw new Error("expect data property to be an array");
}
for (const item of x.data as unknown[]) {
if (typeof item !== "object" || item === null) {
throw new Error("not an object");
}
if (
!("name" in item) ||
!("id" in item) ||
typeof item.name !== "string" ||
typeof item.id !== "number"
) {
throw new Error("missing name or id");
}
}
result.data.forEach(e => doSomethingWithResponse(e.id))
But as you can see… that is a lot of code to get our stuff type-safe. And even then, TypeScript still thinks this is an unknown
in this context.
This is where zod
comes in.
zod is a Library that lets us define schemas. We can then pass any unknown data into the schema.
If the input adheres to the schema, we get the typed response back. The schema will also strip any additional fields by default. If it does not adhere, we get an error:
import {z} from "zod"
const ResponseZod = z.object({
data: z.array(z.object({
name: z.string(),
id: z.number().int()
}))
})
const response = ResponseZod.parse(await fetch("...").then(e => e.json()))
...
This essentially “synchronizes” what we receive from outside our control with what we would expect.
For simple applications, this is where we could stop. If you however have a more complex app with multiple devs working on the Backend and the Frontend, with Endpoints being added and modified regularly, you will eventually run into issues because someone forgot to adjust the Schema on the Frontend.
Zod-Schemas can also be generated from JSON Schemas. It’s not a 1-to-1 Mapping, but it’s good enough. So as long as your Backend Stack allows you to generate JSON Schemas, you’re good to just autogen the Schemas.
Let’s look at an example using a Rust Backend written with axum
.
Given a Backend App like this:
[dependencies]
axum = "0.8.4"
serde = { version = "1.0.219", features = ["serde_derive"] }
tokio = { version = "1.45.1", features = ["rt-multi-thread"] }
with the following main.rs
:
use axum::{
Json, Router,
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
};
use serde::Serialize;
#[derive(Serialize)]
struct ResponseBody {
data: Vec<ResponseBodyItem>,
}
#[derive(Serialize)]
struct ResponseBodyItem {
name: String,
id: u64,
}
async fn fetch_data() -> Response {
let data = vec![
ResponseBodyItem {
name: "User1".into(),
id: 1,
},
ResponseBodyItem {
name: "User2".into(),
id: 2,
},
];
(StatusCode::OK, Json::from(ResponseBody { data })).into_response()
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/api/data", get(fetch_data));
let listener = tokio::net::TcpListener::bind("0.0.0.0:5000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
We have an API Endpoint at localhost:5000/api/data
which returns our JSON Response. The JSON is defined in the ResponseBody
struct, which nests a Vector of ResponseBodyItem
s.
Using schemars
we can generate JSON Schemas for our Structs. We can then serve them via a separate endpoint or write them somewhere during startup. We will be using an endpoint here.
First run cargo add schemars
and add the JsonSchema
macro to any Struct (and referenced struct!) that is part of the Response:
+ use schemars::JsonSchema;
# ...
- #[derive(Serialize)]
+ #[derive(Serialize, JsonSchema)]
struct ResponseBody {
data: Vec<ResponseBodyItem>,
}
- #[derive(Serialize)]
+ #[derive(Serialize, JsonSchema)]
struct ResponseBodyItem {
name: String,
id: u64,
}
Then you can generate a schema for the response type using schema_for!(ResponseBody)
.
This returns you a Schema
instance, which is just a wrapper for serde_json
’s Value
, which you can just return it with axum:
async fn get_schemas() -> Response {
let data = vec![schema_for!(ResponseBody)];
(StatusCode::OK, Json::from(data)).into_response()
}
// ...
let app = Router::new()
.route("/api/data", get(fetch_data))
.route("/api/schemas", get(get_schemas));
This returns an array of JSON Schemas, like so:
[
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "ResponseBody",
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/$defs/ResponseBodyItem"
}
}
},
"required": [
"data"
],
"$defs": {
"ResponseBodyItem": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "uint64",
"minimum": 0
},
"name": {
"type": "string"
}
},
"required": [
"name",
"id"
]
}
}
}
]
We now have a Backend that throws JSON Schemas at us. We can use a script and json-schema-to-zod
to generate the schemas.
Here is a script from one of my projects. It uses vite-node as a runner, but you can also convert to JS or use ts-node
// npx vite-node autogenerateZodSchemas.ts
import { resolveRefs } from "json-refs";
import { jsonSchemaToZod } from "json-schema-to-zod";
import { format } from "prettier";
// This contains the Array of Schemas
const response = await fetch("http://localhost:5000/api/schemas").then((e) =>
e.json(),
);
const definitions = await Promise.all(
(response as Record<string, any>[]).map(async (e) => ({
title: (e as { title: string }).title,
schema: jsonSchemaToZod(await resolveRefs(e).then((e) => e.resolved)),
})),
);
const contents = [
"/// THIS FILE IS AUTOGENERATED",
"/// DO NOT CHANGE IT MANUALLY",
"",
"import {z} from 'zod'",
"",
"",
...definitions.map(
(e) =>
`export const ${e.title}Zod = ${e.schema}; export type ${e.title} = z.infer<typeof ${e.title}Zod>;\n\n`,
),
];
const prettyContents = await format(contents.join("\n"), {
parser: "typescript",
});
console.log(prettyContents);
export {};
This will generate a TS Module like
/// THIS FILE IS AUTOGENERATED
/// DO NOT CHANGE IT MANUALLY
import { z } from "zod";
export const ResponseBodyZod = z.object({
data: z.array(z.object({ id: z.number().int().gte(0), name: z.string() })),
});
export type ResponseBody = z.infer<typeof ResponseBodyZod>;
The last line there essentially replaces the
interface Response {
data: {name: string, id: number}[]
}
we defined at the very start of the article. The type here is equal to whatever is returned by YourSchema.parse(...)
.
Let’s revisit the example from the start. You can now import from the generated schema.
import {ResponseZod} from "./generated.ts"
function doSomethingWithResponse(id: number) {
...
}
const response = ResponseZod.parse(await fetch("...").then(e => e.json()))
response.data.forEach(e => doSomethingWithResponse(e.id))
Now, let’s assume the Backend has a breaking change. For example, we switch from uint64
ids to UUID strings.
If you change the response struct in the backend, this will also reflect in the JSON Schema. If we then rerun the Schema Generation, we will see a .string()
instead of a .number()
.
This will also cause a compile-time error! Why? Because we are now passing a string
to doSomethingWithResponse
, although it expects a number
.
If we update the Backend but don’t touch the Frontend, the Schema.parse()
will fail and prevent further execution. You can then use a try-catch Block to handle errors.
There is also Schema.safeParse()
, which is a better alternative because it does not throw the error but returns if the schema was successful and has strong typing, even for the errors.
I made a blog article looking at this approach to syncing the Front- and Backend for a Spring Boot Backend. The article is available here (german only).
If you would like a more complex example using a Rust Backend (axum) and a React Frontend (Remix), look no further than here: Backend · Frontend
This example doesnt just return a flat list of Schemas, but groups them per topic. Also here the Schema Endpoint is only exposes via a debug build.