Command Palette

Search for a command to run...

Hectal
PHASE 15Intermediate ~28 min· topic 5 of 6

Topic 15.5

Sharing Types Across Front End and Back End

In one line

The front end and the back end must agree on what a request and a response look like. You can share one written contract, generate types from an OpenAPI or GraphQL schema, or derive client types from server code, and you must still check data at the boundary because types vanish at runtime.

Think of it like this

Real-life example: A restaurant menu. The kitchen and the waiters read the same menu. If the kitchen changes "soup" to "soup of the day" and the waiters keep an old copy, a customer orders soup and gets confusion. A shared contract is one menu that both sides read. In code, the menu is a type: the server must produce it, and the client may rely on it.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Contract
The agreed shape of each request and response between a client and a server.
OpenAPI
A standard file format (JSON or YAML) that describes a REST API: its URLs, inputs and outputs.
GraphQL schema
A typed description of the data a GraphQL server offers and the queries clients may ask.
Code generation
A tool that writes source files for you, here TypeScript types from a schema file.
tRPC
A library where the client gets types straight from the server's TypeScript functions, with no schema file.
Boundary
Any place where data enters your program from outside: network, files, storage, user input.
Validate
Check at runtime that data has the shape you expect, and throw or return an error if not.
Schema (runtime)
A value that describes a shape and can both check data and produce a TypeScript type.
Breaking change
A change to the contract that makes old clients fail, such as renaming or removing a field.

Step by step

01Write the contract once

List each route with its request and response types. Both sides import this one object type. Here it is in one file; in a real project it would be a shared package.

packages/shared/src/contract.tswhole filets
export interface User {
  id: string;
  name: string;
}

export interface Contract {
  "GET /users": { request: { limit: number }; response: User[] };
  "POST /users": { request: { name: string }; response: User };
}
export type Route = keyof Contract;

02Or generate it from an OpenAPI file

Here is a small OpenAPI 3.0 file with one route and a User schema. required lists the fields that must be present.

api.jsonwhole filejson
{
  "openapi": "3.0.3",
  "info": { "title": "Shop", "version": "1.0.0" },
  "paths": {
    "/users/{id}": {
      "get": {
        "operationId": "getUser",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
        "responses": {
          "200": { "description": "ok", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } } },
          "404": { "description": "not found" }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "User": {
        "type": "object",
        "required": ["id", "name"],
        "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "age": { "type": "integer" } }
      }
    }
  }
}

03Run the generator

openapi-typescript api.json -o schema.d.ts wrote the file below (version 7.13.0; the paths interface, the unused put?: never lines, the empty headers objects and the parameters boilerplate are cut here for space). Notice age?: number (not in required) and the response keyed by status code.

schema.d.ts (generated, abridged)whole filets
export interface components {
    schemas: {
        User: {
            id: string;
            name: string;
            age?: number;
        };
    };
}
export interface operations {
    getUser: {
        parameters: { path: { id: string } };
        responses: {
            /** @description ok */
            200: { content: { "application/json": components["schemas"]["User"] } };
            /** @description not found */
            404: { content?: never };
        };
    };
}

04Regenerate in the build, never edit by hand

The file says "Do not make direct changes". Put the generator in a script ("gen:api": "openapi-typescript api.json -o src/schema.d.ts") and run it before tsc. When the back end changes the schema, the next build shows every client call that no longer fits.

05Derive the client from the server (tRPC idea)

With a TypeScript back end you can skip the schema: export the router's *type* and compute the client from it. Only the type crosses over, and import type is erased from the browser bundle. The third live example does this in one file.

Derive the client from the server (tRPC idea)diagram
Rendering diagram…

06Validate where data enters

None of the three ways checks the data. A JSON.parse result is any no matter how perfect the contract is. Parse it with a schema at the edge, and use the validated value everywhere inside. The second live example shows a schema-built type and what a failed check looks like.

Try it yourself

  1. 1

    Add a route

    In the first example add "DELETE /users": { request: { id: string }; response: { ok: boolean } }; to Contract. Predict what the compiler says about handlers. (It reports a missing property: every route needs a handler.) Then add the handler and call it from the client.

  2. 2

    Make the data lie

    In the second example change the first readUser call's text to '{"id":"u1"}'. Predict the message (expected a number: age is missing, so undefined is not a number). The compiler never sees the text, only the schema does.

  3. 3

    Rename on the server

    In the third example, rename userById in appRouter to getUser. Which lines fail to compile? In a real run the errors are on the client object: its userById key is no longer in Client<AppRouter> (TS2353), the appRouter.userById call inside it no longer exists (TS2339), and its input parameter loses its type (TS7006). The later client.userById(...) calls are not flagged at all: found silently becomes any. So here the change is caught where the client object is written. (In the real tRPC library the client is created from the router type, so there is no hand-written object like this one.)

Code & diagrams

One contract, a typed server and a typed client

The route name picks both types. The handlers object is a mapped type over Route, so adding a route to Contract makes the server fail to compile until it has a handler, and the client gets it for free. Note the as in fakeFetch and call: the network gives text, so these are the two places that need a runtime check in a real app (next example).

Console

(nothing was printed)

Expected console output

Ravi 2
[ { id: 'u1', name: 'Asha' }, { id: 'u2', name: 'Ravi' } ]
A type generated from a schema object, and a check at the boundary

This is the idea behind Zod's z.object and z.infer, in about 30 lines. The type is computed from the schema value, so the check and the type cannot disagree. The second call has age as text: the compiler can't see that (it is JSON), but the schema throws, which is the whole point of validating at the boundary.

Console

(nothing was printed)

Expected console output

{ id: 'u1', age: 30 }
expected a number
expected a string
31
tRPC-style: the client type is computed from the server

Client<AppRouter> turns every server function into an async function with the same input and Awaited output (Topics 9.3 and 10.2). In the real library that body is an HTTP call; here it calls the router directly so the example can run. userById can return undefined, and the type says so.

Console

(nothing was printed)

Expected console output

Asha u2
undefined

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Typo in a request

In the first example, change { name: "Ravi" } in the POST /users call to { nam: "Ravi" }.

terminal
$ Types panel
── what you'll see ──
index.ts(40,45): error TS2561: Object literal may only specify known properties, but 'nam' does not exist in type '{ name: string; }'. Did you mean to write 'name'?

Break #2

Wrong type in a call

In the third example, change client.userById({ id: "u1" }) to client.userById({ id: 1 }).

terminal
$ Types panel
── what you'll see ──
index.ts(28,39): error TS2322: Type 'number' is not assignable to type 'string'.

Break #3

The data disagrees with the type

In the second example look at the second readUser call: the JSON text has "age":"30" (text, not a number). Run it.

terminal
$ Console
── what you'll see ──
expected a number

Myth vs fact

Myth

If the front end and the back end share types, the data is safe.

Fact

Types vanish at runtime. The network carries plain text, and a bug, an old version or an attacker can send anything. Validate at the boundary.

Myth

Generated types mean I don't need to write code to call the API.

Fact

A generator writes the types, not the calls. Libraries such as openapi-fetch (0.17.0 on npm) build a typed client on top of them.

Myth

tRPC puts my server code in the browser.

Fact

The client imports only the router's type with import type, and type imports are erased. No server code is bundled.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    OpenAPI-generated types use never for things that must not exist (put?: never, content?: never) so that using them is an error. The output is a .d.ts file: it has only types and costs nothing at runtime.

  • ▸

    A schema library that exposes both a validator and a type (Zod's z.infer) gives a single source of truth. If your database layer, API layer and forms all derive from the same schema, a change propagates everywhere at compile time.

  • ▸

    Be careful with Date, Map and bigint in a shared type: JSON can't carry them. After JSON.stringify a Date becomes a string, so the type on the receiving side is string unless a schema converts it back.

  • ▸

    Next.js Server Components and server functions change this picture in the App Router: the server and client share one program, so types flow through props without a network contract. The Next.js docs in this repo say the data fetched on the server is not serialized between fetching function and page. Data passing through a real HTTP API still needs a contract, and values handed to Client Components still have to be serializable.

Remember this

  1. 1

    The problem this solves: the browser code and the server code are written separately. When the server changes a field name, the browser keeps asking for the old one, and nothing fails until a user hits the broken page. Sharing the description of the data, and checking the data when it arrives, finds that mismatch early.

  2. 2

    Recall before you start. Topic 8.4: a mapped type { [K in keyof T]: ... } builds an object type from another. Topic 9.3: infer pulls a type out of another type. Topic 5.6: satisfies checks a value against a type without widening it. Topic 4.7: a type guard checks a value at runtime and tells the compiler the result.

  3. 3

    A contract (also called an *API contract*) is the agreed shape of each request and response: which URL, which inputs, which result. Without one, the client author guesses what the server sends and the guess goes stale. With TypeScript on both sides you can write the contract as types and let tsc complain about every place that disagrees. The first live example shares one Contract between a fake server and a fake client in a single file.

  4. 4

    Way 1: a shared package or folder. Put the types (and maybe validators) in a package that both the front end and back end import (Topic 15.6 shows how packages are wired in a monorepo). It is simple and fast, and it only works when both sides are TypeScript and live in one repository (or the package is published).

  5. 5

    Way 2: generate types from a schema. If the contract is a language-neutral file, such as an OpenAPI document (a JSON/YAML description of a REST API, that is, a server you talk to with web addresses and methods such as GET /users) or a GraphQL schema, a tool turns it into TypeScript types. openapi-typescript (7.13.0) read a small OpenAPI file in the scratch project and wrote components["schemas"]["User"] as { id: string; name: string; age?: number }, turning required into non-optional fields. For GraphQL the usual tool is GraphQL Code Generator (@graphql-codegen/cli, 7.4.6 on npm). The schema is the source of truth, so a back end in Java or Go can feed a TypeScript client.

  6. 6

    Way 3: derive the client from the server (tRPC style). In a TypeScript-only stack, the server's functions *are* the contract. tRPC (@trpc/server 11.19.0) lets the client import only the *type* of the server's router (import type { AppRouter } from "./server"), which is erased at build time, so no server code ships to the browser. The third live example reproduces the mechanism with a mapped type over the server's object: typeof appRouter is the contract and Client<AppRouter> is computed from it.

  7. 7

    The boundary is still a lie detector's job. Types exist only while compiling. The network delivers text; JSON.parse returns any, and as User is a promise, not a check (Topic 13.4). If the server ships a bug, an old version, or an attacker's payload, the client's types are wrong and nothing notices. So you validate at the boundary: parse incoming data with a runtime validator and let the validated result carry the type. The second live example builds a tiny schema library whose parse throws on bad data and whose type is *computed from the schema object*, so the check and the type cannot drift apart.

  8. 8

    Production code usually uses a library for this instead of hand-written schemas: Zod (4.6.6 on npm) is the popular one, and z.infer<typeof Schema> gives the type. Validate on both sides: the server validates requests (never trust the client) and the client validates responses (never trust the network). Keep secrets, ids and permission checks on the server; a type tells the compiler what a field is, never who is allowed to see it.

  9. 9

    Contracts change. Prefer additive changes (a new optional field) over breaking ones (renaming, removing). A generated or shared type makes a breaking change fail the build on the other side immediately, which is the point: you find out in CI, not from a user. For versioned public APIs, keep old versions alive until clients move.

Explain it without notes

01

What are three ways to keep a front end and a back end in agreement on types?

02

Why do you still need runtime validation if the client and server share types?

03

How does a tRPC-style client know the server's types without importing server code?

04

How does a type get generated from an OpenAPI schema, and what do you do when the schema changes?

Practice

01

Add a "GET /users/:id" route to the first example's Contract with request { id: string } and response User | undefined, add its handler, and call it from the client. What does the compiler force you to do?

02

Extend the schema library in the second example with s.boolean() and a UserSchema field admin: s.boolean(). Show the inferred User type.

Trade-offs

  • ↔

    Shared types are the least setup, but couple the two sides to one language and one repository. Schemas (OpenAPI, GraphQL) cost a generation step and give you language independence and documentation.

  • ↔

    tRPC-style derivation gives the best day-to-day experience for a TypeScript-only stack, and ties the client to the server's code structure; public APIs for other teams still want an explicit schema.

Done when you can

  • I can write an API contract as types and use it on both sides.

  • I can generate types from an OpenAPI file and explain why not to edit them.

  • I can explain how a client gets types from the server without importing its code.

  • I can say why types don't replace validation at the boundary.

  • I can build a type from a schema object so the check and the type match.