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.
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:
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
- 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.
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:
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
- Define schemas at the boundary (HTTP, queue, form, DB row)
- Infer types with
z.infer(or your validator’s equivalent) - Share with
import type/ a contracts package - Model UI/domain state as unions
- 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
Make TypeScript check your strings: template literal & mapped types
Model routes, events, and keys as types, not `string`, so typos become red squiggles before runtime.
TypeScript generics that stop fighting you
Good generics constrain and infer. Callers pass values and get precise types back, with no `<T>` by hand.
TypeScript `satisfies` vs `as`: stop silencing the compiler
`as` isn’t a conversion, it’s a promise. `satisfies` checks the value and keeps the exact inferred shape.