Learn/TypeScript Deep Dives

TypeScript Deep Dives · Part 5

End-to-end TypeScript type safety without codegen

Duplicated backend and frontend types always drift. Learn how to infer types from one schema, share them with `import type`, and make breaking changes fail the build, without a generate step to babysit.

8 min read
TypeScriptFull-stackZodType safety

Duplicated types always drift

Backend says { name }. Frontend redeclares { fullName }. They agree, until someone renames a field.

Hand-maintained interfaces that “mirror” the API are not a contract. They are drift waiting to happen, and the mismatch surfaces in production, not in tsc.

Infer, don’t redeclare

If you already validate at the boundary with a schema, derive the TypeScript type from that schema:

ts
import { z } from "zod";
 
const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
});
 
type User = z.infer<typeof UserSchema>;

One source of truth:

  • Runtime validation uses UserSchema
  • Compile-time types use User
  • Rename a field once, both sides update
ts
- interface User { name: string } // by hand, will drift
+ type User = z.infer<typeof UserSchema>;

Share types, not server code

You want shapes on the client without bundling database clients or secret-handling modules.

ts
import type { User } from "@/server/types";

import type is erased at build time. Pair it with a shared package or a thin types / contracts module that both apps depend on.

Make illegal states unrepresentable

End-to-end safety is not only “same field names.” It is also modeling states so bad combinations cannot exist:

ts
type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User }
  | { status: "error"; error: string };

A success without data cannot compile. Exhaustive switch statements catch missing cases when you add a new status.

No codegen to babysit

Codegen (OpenAPI → types) is valuable for external APIs. For your app’s internal boundary, inference often wins:

  • No generate step to forget in CI
  • No stale generated/ folder in PRs
  • A breaking schema change fails the build immediately

The goal: a mismatch fails CI, not your users.

How the pieces fit

  1. Define schemas at the boundary (HTTP, queue, form, DB row)
  2. Infer types with z.infer (or your validator’s equivalent)
  3. Share with import type / a contracts package
  4. Model UI/domain state as unions
  5. Keep stringly-typed APIs and loose generics out of the hot path

Bottom line

One schema. Zero drift. Database → API → UI.

You do not need a bigger generate pipeline to get there, you need a single source of truth and a compiler that is allowed to break the build when the truth changes.

FAQ

How do you share types between backend and frontend without bundling server code?+

Use `import type` so types are erased at compile time. Keep runtime schemas in a shared package or a boundary module that both sides can import safely.

Do I need OpenAPI codegen for end-to-end type safety?+

Not always. Inferring types from a single runtime schema (for example with Zod’s `z.infer`) can give you DB → API → UI safety without a generate step. Codegen still helps for external/third-party APIs.

What does “make illegal states unrepresentable” mean?+

Model domain states as discriminated unions so impossible combinations cannot be constructed. Exhaustive checks then force every branch to be handled at compile time.

TypeScript workshops for teams

We wire end-to-end type safety on your stack, schema → API → UI, without a fragile codegen ritual.

View workshops →

Keep learning