A common assumption when teams adopt TypeScript: "now our APIs are type-safe." They're not. Types disappear at runtime, and the JSON crossing the network has no idea what interface User says.
Real type-safe APIs need three layers: stable contracts, runtime validation at the boundary, and disciplined separation between transport shapes and domain models. Let's walk through what each one buys you.
Contracts Should Be Small And Honest
A contract describes what the endpoint accepts and returns. Keep it focused on what the client needs — not on what your database table looks like.
export type CreateUserRequest = {
email: string;
name: string;
};
export type UserResponse = {
id: string;
email: string;
name: string;
createdAt: string;
};
export type ApiError = {
code: string;
message: string;
requestId: string;
};
Three small types. No passwordHash. No internalScore. No timestamps for fields the UI doesn't care about. The contract is a deliberate slice of your model — what you're willing to commit to externally.
When mobile or partner clients depend on this shape, every field becomes a long-term promise. Adding fields is safe; removing or renaming them is not. Design the surface as if you'll regret every field you didn't need.
DTOs Keep Transport Separate From Domain
The trap most teams fall into: using one type for "the user in the database," "the user in the API response," and "the user in the form." All three are different shapes with different lifetimes.
// internal — full database row, includes secrets
export type UserRecord = {
id: string;
email: string;
name: string;
passwordHash: string;
createdAt: Date;
internalScore: number;
};
// transport — what the API returns to clients
export type UserResponse = {
id: string;
email: string;
name: string;
createdAt: string;
};
// transport — what the form sends in
export type CreateUserRequest = {
email: string;
name: string;
password: string;
};
export const toResponse = (u: UserRecord): UserResponse => ({
id: u.id,
email: u.email,
name: u.name,
createdAt: u.createdAt.toISOString(),
});
The mapping function looks redundant. It also makes it impossible to leak passwordHash through the API by accident — the type system refuses to allow it. The first time someone adds a sensitive column to UserRecord, you'll be glad the boundary exists.
Validation Is The Runtime Boundary
Types stop at compile. Use a schema parser at the network edge to enforce them at runtime. Zod, valibot, ajv, and arktype all let you define a schema once and derive both the runtime check and the TypeScript type from it.
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(120),
});
type CreateUserRequest = z.infer<typeof CreateUserSchema>;
app.post('/users', async (req, res) => {
const parsed = CreateUserSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(422).json(formatError(parsed.error));
}
const user = await createUser(parsed.data);
res.json(toResponse(user));
});
The schema is the single source of truth. Change the validation rules, the TypeScript type updates automatically. There's no two-place edit and nothing for the types and the runtime check to disagree about.
Versioning Is A Product Decision
Once an API has external consumers, every change is either backward-compatible or a version bump. There is no third option, even if the diff looks small.
Backward-compatible: adding new optional fields, adding new endpoints, adding new enum values that clients can ignore.
Breaking: removing fields, renaming fields, changing field types, changing error shapes, tightening validation that previously passed.
Pick a versioning strategy early — URL prefix (/v1/, /v2/), header (Accept-Version: 2), or media type — and stick with it. The strategy matters less than the consistency.
A Consistent Error Envelope
The day a client team asks "what does an error look like?" is the day you wish you'd standardized. Pick one shape early, use it for every error, never change it without a version bump:
{
"error": {
"code": "validation_failed",
"message": "The data you sent failed validation.",
"fields": { "email": ["Invalid email format"] },
"requestId": "req_01HZK..."
}
}
The requestId is the small touch that pays back hardest — every support ticket can reference one ID and you grep one log query to find the request.
Pro Tips
- Schema first, type inferred. Zod / valibot / ajv with
z.inferkeeps the runtime and the type in sync. - Map at the boundary. Internal models stay rich; API DTOs stay minimal.
- Design errors like success. A consistent envelope is part of the contract.
- Version the URL or the header — not both. Half-versioned APIs confuse everyone.
- Generate clients from contracts. Tools like ts-rest, tRPC, or OpenAPI codegen close the loop end-to-end.
Final Tips
A type-safe API is not the one with the most TypeScript features. It's the one where contracts are small, validation is real, and the next mobile release won't break because someone "tightened" a field.
Build the boundary like you mean it. The compiler can only protect what it can see.
Good luck shipping APIs you'd actually want to consume 👊


