Home / Blog / TypeScript /How to Build Type-Safe APIs with TypeScript: A Practical Guide for Engineering Teams

TypeScript

October 23, 2026 - by Devico Team

How to Build Type-Safe APIs with TypeScript: A Practical Guide for Engineering Teams

TypeScript stops protecting your application at the point where unverified data enters it.

An HTTP request body may not match the interface assigned to it. A third-party webhook can omit a required field. JSON.parse can return an object with a completely different structure from the one the application expects. Even a database row may differ from the model declared in the code.

The compiler cannot verify any of this. It only checks the assumptions expressed through types.

That distinction matters because a frontend and backend can both compile successfully while disagreeing about whether createdAt is a string, a number, or missing altogether. The code is typed, but the data crossing the boundary is not necessarily safe.

Building type-safe APIs with TypeScript therefore requires more than enabling the compiler. Engineering teams need to decide how static types, runtime validation, shared contracts, and automated contract tests should work together — and which tools justify their operational cost.

What a Type-Safe API Actually Guarantees

The phrase "type-safe" often combines two separate guarantees.

Compile-time safety means TypeScript checks whether the code uses declared types consistently while developers write and build the application.

Runtime safety means the application checks whether the data it receives actually conforms to those types.

TypeScript provides the first guarantee. Its types disappear during compilation, leaving no runtime mechanism that can inspect an incoming payload and determine whether it matches an interface.

JSON.parse is a familiar example. Its return type is any, so the result can be assigned to an interface without verification:

interface User {
  id: string
  email: string
}

const user: User = JSON.parse(payload)

This compiles even if payload contains neither id nor email. As the TypeScript handbook explains, any disables type checking for that value.

The same problem appears with fetch().then(r => r.json()), untyped SDKs, external APIs, webhooks, and type assertions using as. In each case, the compiler accepts the developer's claim without confirming the underlying data.

A genuinely type-safe API needs one of two protections at every boundary:

  • Runtime validation that checks external data before the application uses it.

  • An end-to-end contract derived from a shared source of truth.

Static types remain essential, but they cannot provide either guarantee on their own.

The Four Layers of a Type-Safe API Stack

Type safety works better as a layered system than as a property supplied by one library. Each layer prevents a different failure mode.

Layer 1: Static types

Interfaces, generics, discriminated unions, and branded types describe the data that application code is allowed to manipulate. They catch inconsistencies in code that already operates on correctly shaped values.

They cannot verify data arriving from outside the program.

Layer 2: Runtime validation

Schema libraries such as Zod, io-ts, and Valibot inspect payloads when they enter the application.

A useful schema library also derives the TypeScript type from the runtime schema. This removes the need to maintain an interface and a validator separately — a common source of drift.

The same definition then serves two purposes:

  • It validates the data at runtime.

  • It produces the type used during compilation.

This layer closes the unchecked gaps introduced by any, type assertions, request bodies, and third-party responses.

Layer 3: Contract synchronization

Runtime validation protects an individual service from malformed input. It does not ensure that the client and server share the same understanding of the API.

OpenAPI code generation and tRPC address that problem differently. Both establish one authoritative API definition from which consumers derive their types. When the contract changes, client-side type checking exposes incompatible calls before they reach production.

Layer 4: Contract testing

Shared types still cannot guarantee that independently deployed services behave according to the contract.

Consumer-driven contract tests, commonly implemented with Pact, compare a consumer's expectations with the provider's actual responses. Running these checks in CI catches contract drift before either side is deployed.

Runtime Validation: Zod vs. io-ts vs. Valibot

Zod, io-ts, and Valibot can all infer TypeScript types from runtime schemas. The more consequential differences are their syntax, bundle footprint, ecosystem support, and assumptions about how a team writes TypeScript.

Zod: the practical default

Zod uses a chainable API that feels familiar in most TypeScript codebases:

const User = z.object({
  id: z.string(),
  email: z.string().email(),
})

Its ecosystem is much larger than those of its closest alternatives. As of August 2026, Zod receives more than 250 million weekly downloads on npm. That level of adoption affects more than popularity: developers are easier to onboard, integrations are common, and framework documentation frequently includes Zod examples.

Zod 4 also reduced bundle size and improved parsing performance compared with version 3. For applications where every kilobyte matters, Zod Mini provides a more tree-shakeable alternative to the standard package.

For most teams, Zod is the lowest-friction choice.

io-ts: a better fit for functional TypeScript

io-ts takes a functional approach built around fp-ts conventions. Validation returns an Either, which the application decodes and folds instead of relying on exception-based control flow.

That model is rigorous, but it assumes familiarity with functional composition. The extra conceptual overhead can be worthwhile in an existing fp-ts codebase; it is harder to justify when a team would be adopting both io-ts and its programming model at the same time.

Its download trend has settled at a few million weekly installs — a small fraction of Zod's usage. io-ts remains stable and useful, but it is now a specialist choice rather than the obvious starting point for a new project.

Valibot: smaller, modular schemas

Valibot reached its 1.0 release with a design aimed directly at bundle-sensitive applications.

Instead of exposing one large chainable API, it builds schemas from individually imported functions. Bundlers can remove unused functionality, allowing a small schema to occupy roughly 1–2 kB gzipped, compared with about 5 kB for standard Zod 4.

That difference can matter in browser bundles, edge functions, and serverless applications sensitive to cold-start size. The trade-off is a more verbose composition style and a younger integration ecosystem.

Library
Approximate bundle size, gzipped
TypeScript inference
Developer experience
Maintenance and adoption

~5 kB standard; ~4 kB Zod Mini

Excellent

Chainable, imperative API with low onboarding friction

Very active; more than 250M weekly downloads

~10 kB, plus fp-ts

Excellent, with fp-ts conventions

Steeper learning curve; Either and decode-based workflows

Stable; low single-digit millions of weekly downloads

~1–2 kB for a minimal schema; grows with imports

Excellent

Modular function composition; somewhat more verbose

Active and growing; roughly 15–17M weekly downloads

Download counts reflect npm data from August 2026. Bundle sizes are approximate and version-sensitive, so verify them against the current package pages before adopting them as internal benchmarks.

The practical choice is usually straightforward. Use Zod unless the project has a concrete reason not to. Valibot deserves consideration when bundle size or cold starts are measurable constraints. io-ts makes the most sense where fp-ts is already part of the engineering model.

OpenAPI Codegen vs. tRPC

Validation checks whether a payload is valid when it reaches one service. A contract system deals with a different problem: keeping the client and server aligned as the API changes.

OpenAPI and tRPC both reduce drift, but they optimize for different team structures.

OpenAPI-first: a language-neutral contract

With an OpenAPI-first workflow, the OpenAPI document is the authoritative contract. Tools such as openapi-typescript generate TypeScript types — and, where required, typed clients — from the specification.

Because the contract is language-neutral, it can support a React frontend, a native iOS application, a Python service, and a partner integration without forcing all consumers onto one runtime or repository.

That flexibility comes with process overhead. The specification must be versioned, reviewed, and kept in sync with the implementation. Code generation also becomes part of the build. If the spec is treated as documentation written after development, it will eventually stop describing the real API.

tRPC: server types become the contract

tRPC avoids a separate specification and code-generation step. The server router defines the contract, and the client imports its types directly. Calls are typed end to end, with autocomplete and immediate compiler errors when procedure inputs or outputs change.

For a full-stack TypeScript monorepo owned by one team, this is difficult to beat for development speed.

The limitation is the same feature that makes tRPC productive: tight TypeScript-to-TypeScript coupling. A non-TypeScript client cannot consume a tRPC router natively. According to the official documentation, REST or other-language access requires the @trpc/openapi plugin to generate an OpenAPI specification. That package remains marked as alpha, and features such as subscriptions are not included in the generated specification.

Dimension
OpenAPI codegen
tRPC

Source of truth

OpenAPI/Swagger specification

Server router types

Supported clients

Any language that can consume the specification

TypeScript; non-TypeScript consumers require an OpenAPI bridge

Build step

Code generation from the specification

None; types are imported directly

Coupling

Loose, with an independently versioned contract

Tight, through shared TypeScript types

Best fit

Public APIs, mobile clients, partner APIs, and polyglot systems

Internal APIs in a full-stack TypeScript monorepo

Becomes awkward when

The specification falls behind the implementation

A public or non-TypeScript consumer is introduced

tRPC gains speed by assuming that every participant speaks TypeScript. OpenAPI accepts more ceremony in exchange for portability. If the API will only serve a TypeScript frontend controlled by the same team, tRPC's coupling is useful. Once mobile applications, partners, or services written in other languages become part of the system, a language-neutral contract is usually the safer foundation.

How to Choose the Right Type-Safety Stack

Tool comparisons are less useful than understanding the constraints that eliminate unsuitable options. Four questions usually settle the decision.

Who consumes the API?

If every consumer is a TypeScript application owned by the same team, direct type sharing is possible.

If consumers include native mobile apps, external partners, or services written in other languages, the contract needs to remain independent of TypeScript.

Is the API internal or externally exposed?

An internal API can tolerate tighter coupling and coordinated deployments. Public and partner-facing APIs usually require explicit documentation, versioning, deprecation rules, and generated clients.

Is the backend TypeScript-only?

A single TypeScript backend can share schemas and inferred types directly. Polyglot services need an interchange format such as OpenAPI or JSON Schema.

What infrastructure already exists?

Existing investments change the calculation. A maintained OpenAPI specification, an fp-ts codebase, or an established monorepo should not be discarded merely to adopt the currently fashionable tool.

From those constraints, several combinations emerge.

Small full-stack TypeScript team with an internal API: use tRPC for the contract and Zod for data that still enters from outside the trusted system, including webhooks and third-party responses. This produces the least ceremony and the fastest feedback.

Growing product with public or mobile consumers: use OpenAPI as the contract, generate clients for each platform, and validate inputs with Zod. Valibot can replace Zod at bundle-sensitive edges.

Polyglot microservices: use OpenAPI or JSON Schema as the interchange format, validate inside each service, and run Pact contract tests in CI. tRPC's TypeScript coupling offers little value here.

Size-constrained edge or serverless application: use Valibot for runtime validation, then choose tRPC or OpenAPI based on consumer topology rather than bundle size.

This is a practical default, not a universal architecture rule. Its purpose is to expose the constraint behind the tool choice. Many arguments about Zod, tRPC, or OpenAPI are really disagreements about which consumers and deployment patterns the system must support.

Implementing Runtime Validation with Fastify and Zod

The following example adds runtime validation to a Fastify route. The same boundary-first pattern works with Express or NestJS.

Define the schema once

import { z } from "zod"

const CreateUser = z.object({
  email: z.string().email(),
  age: z.number().int().positive(),
  role: z.enum(["admin", "member"]),
})

type CreateUser = z.infer<typeof CreateUser>

The schema is the source of truth. CreateUser validates incoming data at runtime, while z.infer produces the static TypeScript type. There is no parallel interface to update manually.

Validate before data enters the domain

app.post("/users", (req, reply) => {
  const parsed = CreateUser.safeParse(req.body)
  if (!parsed.success) {
    return reply.code(400).send({
      error: parsed.error.flatten(),
    })
  }
  const user = parsed.data
  return reply.code(201).send(createUser(user))
})

safeParse makes the invalid branch explicit. The handler cannot treat the request body as a CreateUser until validation succeeds.

After that check, parsed.data is both statically typed and known to match the schema at runtime. If the schema changes, its inferred type changes with it, and incompatible downstream code begins failing during type checking.

The error branch deserves the same attention. Returning raw exceptions or an untyped object simply moves the any problem from successful responses to failed ones.

Use compiler settings that expose unsafe assumptions

Runtime schemas cannot compensate for a permissive compiler configuration. A sensible baseline starts with strict, which enables noImplicitAny, strictNullChecks, and the rest of TypeScript's strict family.

Two settings outside that family are particularly relevant to APIs:

  • noUncheckedIndexedAccess changes indexed access from an assumed T to T | undefined.

  • exactOptionalPropertyTypes distinguishes between an absent optional property and a property explicitly set to undefined.

A minimal configuration might look like this:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  }
}

The TSConfig reference documents the remaining options, but these three settings address several of the most common false assumptions around API payloads.

Common Ways Type Safety Erodes

A codebase rarely loses type safety through one major architectural decision. It usually happens through small shortcuts repeated under deadline pressure.

Assertions at external boundaries

This pattern is especially dangerous:

const response = externalPayload as UserResponse

as UserResponse does not inspect the payload. It tells the compiler to accept the developer's claim. At an external boundary — the place where the assumption is least reliable — the check has been removed entirely.

Boundary assertions should therefore receive the same scrutiny as unchecked casts in a memory-unsafe language. Replace them with schema validation whenever the value comes from outside the application's trusted domain.

Optimistic third-party types

SDKs and REST clients may return any, incomplete declarations, or types that describe the provider's intended response rather than every response it can produce.

Validate third-party data before persisting it or passing it into domain logic. An accurate TypeScript declaration cannot protect the application from an upstream service returning malformed JSON.

Typed handlers without runtime parsing

Annotating a route parameter does not validate a request:

app.post('/users', (req: Request<CreateUser>) => {
  // req.body has a compile-time type, but has not been checked at runtime
})

Without an actual parse operation, the application still accepts malformed input as though it matched CreateUser.

Manually duplicated frontend and backend types

Copying interfaces between repositories creates two sources of truth. The copies may begin identically, but they will diverge as soon as one side changes without the other.

This is the strongest argument for generating both clients from OpenAPI or deriving them from a shared tRPC router.

Untyped error responses

Teams often model successful responses carefully while allowing every error path to return a different object. Clients then fall back to any, string matching, or defensive property checks.

Model error responses as discriminated unions so consumers must handle each known case:

type CreateUserResponse =
  | { ok: true; user: User }
  | { ok: false; code: 'INVALID_INPUT'; fields: Record<string, string[]> }
  | { ok: false; code: 'EMAIL_EXISTS' }

The individual fixes are not difficult. Consistency is harder. Validation needs to become the normal way data enters the application, while assertions on untrusted values become code-review exceptions.

Enforcing Type Safety Across Teams and Services

One service with a small group of maintainers can rely on convention for a while. Multiple repositories and independently deployed services need automated enforcement.

Make type checking a required CI gate

Run tsc --noEmit on every pull request and prevent merging when it fails. This is inexpensive and stops type errors from becoming deployment problems.

Lint rules such as no-explicit-any and no-unsafe-assignment help keep unchecked values from spreading through the codebase. Exceptions may still be necessary, but they should be visible and justified rather than accidental.

Share schemas inside a monorepo

When the frontend and backend live in one repository, place shared schemas in a dedicated package imported by both sides.

Tools such as Turborepo and Nx can cache and coordinate type checking as the repository grows. Both remain actively maintained in 2026 and are common choices for TypeScript monorepo builds.

A shared schema package makes drift structurally harder: teams cannot update a server contract without changing the type consumed by the client. Engineering leaders evaluating this setup may also compare how established TypeScript development companies organize shared packages, ownership, and CI checks in larger codebases.

Test contracts between independently deployed services

A shared package becomes less useful when services live in different repositories, use different languages, or deploy on separate schedules.

Pact uses consumer-driven contract tests to verify that the provider still returns what the consumer expects. These checks run in CI before deployment, covering the network boundary that TypeScript cannot reach.

Contract tests should sit alongside a clear policy for breaking changes: version the contract, define a deprecation window, and specify how long older clients remain supported. Without that operational agreement, even accurate contract tests can only report incompatibility after a team has already introduced it.

The Performance and Build-Time Costs

A type-safety stack adds work at runtime, during compilation, or in CI. Hiding those costs makes poor implementations more likely.

Runtime validation consumes CPU

Every schema parse has a cost. For most HTTP endpoints, that cost is small compared with database queries and network calls. It can become visible on high-throughput internal RPCs, stream processing, or endpoints that repeatedly validate large nested payloads.

There is no meaningful universal benchmark. The impact depends on the schema, payload size, traffic pattern, library version, and runtime. Measure the actual workload.

Validation also does not need to be applied with equal intensity everywhere. Validate data where trust has not yet been established: public endpoints, webhooks, third-party APIs, queues, files, and persistence boundaries. Revalidating the same object at every internal function call adds cost without necessarily reducing risk.

Code generation adds another build dependency

OpenAPI code generation improves contract consistency, but it also adds commands, generated artifacts, and possible failure points to CI.

The cost is usually justified when multiple clients depend on the specification. It grows as the organization generates more SDKs and supports more versions.

Type-level complexity slows compilation

Large unions, deeply nested generics, and elaborate conditional types can make tsc noticeably slower in a large monorepo.

Incremental compilation, project references, and build caching help. They do not make complex type-level programming free. If a clever abstraction adds seconds to every editor update or minutes to CI, its value should be measured against that cost.

The appropriate standard is selective trust: validate where the application encounters uncertainty, then preserve the resulting type information so the same work does not need to be repeated.

When Senior TypeScript Expertise Is Worth Bringing In

Most teams can introduce runtime schemas and stricter compiler settings without outside help. The case for additional senior expertise appears when the problem is no longer choosing a library but establishing an architecture that several teams will follow.

Common warning signs include:

  • Client-server mismatches repeatedly reaching production.

  • API schemas duplicated across several packages or repositories.

  • No clear owner for contract changes.

  • New engineers unable to identify the authoritative contract.

  • Several validation libraries introduced under different deadlines.

  • Inconsistent success and error response formats.

  • CI checks that vary between services or can be bypassed.

The issue in these systems is rarely a missing Zod schema. It is the absence of an enforceable standard covering schema ownership, runtime boundaries, generated clients, breaking changes, and CI gates.

An engineer who has designed this kind of API layer before can establish those conventions and leave the team with a repeatable pattern. If internal senior engineers cannot make time for that work, it may be faster to hire experienced TypeScript developers than to keep resolving the same contract failures separately.

The target state is simple even when the implementation is not: one authoritative contract, validation at untrusted boundaries, and automated checks strong enough to survive the next deadline.

Frequently Asked Questions

Is TypeScript alone enough for a type-safe API?

No. TypeScript checks code during compilation, but its types do not exist at runtime. Request bodies, JSON.parse results, third-party responses, and other external values remain unchecked until the application validates them.

Does Zod replace OpenAPI?

No. They address different parts of the system.

Zod validates values inside a running service. OpenAPI defines a language-neutral contract that clients and services can share. A typical stack may use OpenAPI to generate clients and Zod to validate incoming data.

tRPC can take the place of OpenAPI when every consumer is written in TypeScript and can depend directly on the server's router types.

Can an existing untyped API adopt type safety incrementally?

Yes. A full rewrite is usually unnecessary.

Start with public endpoints, third-party inputs, and other high-risk boundaries. Add runtime schemas there, enable strict compiler settings, and fix errors one module at a time. Once those boundaries are controlled, introduce a shared contract or generated clients to reduce frontend-backend drift.

Does tRPC support non-TypeScript clients?

Not natively. tRPC is designed for TypeScript clients consuming TypeScript server routers.

The @trpc/openapi plugin can generate an OpenAPI specification for REST or other-language consumers, but it remains marked as alpha and does not cover every tRPC feature. If non-TypeScript consumers are a firm requirement, an OpenAPI-first design is generally the cleaner choice.

Stay in touch

Leave your email and we will inform you about all our news and updates

 

Up next