Skip to main content

Type-Safe APIs with TypeScript in 2026

·APIScout Team
Share:

Type-Safe APIs with TypeScript in 2026

Type safety across the entire stack — from database schema to API response to frontend component — eliminates an entire class of bugs. No more "undefined is not a function" from a field that was renamed on the backend. TypeScript makes this possible when the right tools connect each layer.

TL;DR

  • The type-safe stack: Drizzle or PrismaZodtRPC or HonoReact Query or tRPC client
  • Zod is the load-bearing layer: one schema definition gives you runtime validation, TypeScript types, and OpenAPI generation
  • For public APIs, generate OpenAPI from Zod schemas and use orval or hey-api for typed client generation — not tRPC
  • Drizzle wins for new projects in 2026: lighter bundle, SQL-like syntax, no Rust binary to compile, better edge runtime support
  • Typed error responses via discriminated unions prevent "catch(e: any)" patterns that hide bugs in production
  • Never use z.parse() in hot paths where you expect failures — use z.safeParse() to avoid exception overhead

The Type-Safe Stack

Database Schema (Prisma/Drizzle)
    → Type-safe ORM queries
API Layer (tRPC/Hono/Express + Zod)
    → Type-safe request validation + response types
Client (React Query + generated types)
    → Type-safe API calls with autocomplete

Layer 1: Database → TypeScript

Prisma

Prisma generates TypeScript types from your database schema:

model User {
  id    String @id @default(uuid())
  name  String
  email String @unique
}

Generates User type and type-safe query methods. prisma.user.findUnique({ where: { id } }) returns User | null with full autocomplete.

Drizzle ORM

Drizzle defines schemas in TypeScript directly:

const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
});

Types are inferred from the schema definition. Queries are type-safe with SQL-like syntax.

Layer 2: API Validation (Zod)

Zod validates runtime data and infers TypeScript types:

const CreateUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().min(13).max(120).optional(),
});

type CreateUser = z.infer<typeof CreateUserSchema>;
// { name: string; email: string; age?: number }

One schema serves as: runtime validation, TypeScript type, and API documentation.

Layer 3: Type-Safe API

tRPC — Zero-API-Layer Approach

// Server
const appRouter = router({
  user: router({
    getById: publicProcedure
      .input(z.object({ id: z.string().uuid() }))
      .query(({ input }) => {
        return prisma.user.findUnique({ where: { id: input.id } });
      }),
    create: publicProcedure
      .input(CreateUserSchema)
      .mutation(({ input }) => {
        return prisma.user.create({ data: input });
      }),
  }),
});

// Client — full type inference, zero code generation
const user = await trpc.user.getById.query({ id: "abc" });
// user is typed as User | null

Hono + Zod — REST with Types

const app = new Hono();

const route = app.post('/users',
  zValidator('json', CreateUserSchema),
  async (c) => {
    const data = c.req.valid('json'); // Typed as CreateUser
    const user = await prisma.user.create({ data });
    return c.json(user);
  }
);

// Generate OpenAPI spec from routes
// Generate client types from OpenAPI

Express + Zod — Traditional REST

Use Zod middleware to validate request bodies, query parameters, and path parameters. Export Zod schemas to generate OpenAPI specs.

Layer 4: Client Types

tRPC Client (automatic)

Types flow from server to client automatically. No code generation needed.

OpenAPI → TypeScript (generated)

For REST APIs, generate TypeScript types from your OpenAPI spec:

npx openapi-typescript api.yaml -o ./types/api.ts

Then use with a typed fetch wrapper or generated client.

React Query + Types

const { data } = useQuery({
  queryKey: ['user', id],
  queryFn: () => trpc.user.getById.query({ id }),
});
// data is typed as User | null | undefined

The Full Pipeline

LayerToolTypes
DatabasePrisma / DrizzleGenerated from schema
ValidationZodInferred from validators
APItRPC / HonoInferred from procedures/routes
ClienttRPC client / openapi-typescriptAutomatic / generated
UIReact + TypeScriptFlows from API types

Benefits

  1. Rename a field on the server → TypeScript errors show everywhere it's used on the client
  2. Add a required field → Compilation fails until all callers provide it
  3. Change a type → No runtime "undefined" errors in production
  4. Autocomplete → Developers explore the API from their editor

When Not to Use tRPC

  • Public APIs — External developers need REST/GraphQL, not tRPC
  • Multi-language backends — tRPC is TypeScript-only
  • Existing REST API — Migration cost may not justify benefits
  • Large distributed teams — API contracts may need to be more explicit

For public APIs, use Zod + OpenAPI generation to get type safety internally while exposing a standard REST interface externally.

Zod in Depth

Zod's basic z.string() and z.object() are just the surface. The library has powerful features that most developers underuse.

Coercion

Coercion automatically converts input types before validation. Useful for query parameters (which arrive as strings) and form data:

const SearchSchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  active: z.coerce.boolean().optional(),
});

// Query: ?page=2&limit=50&active=true
// All strings → coerced to correct types
const params = SearchSchema.parse(req.query);
// { page: 2, limit: 50, active: true }

Without z.coerce, "2" fails a z.number() check. With it, the coercion happens transparently.

Transforms

Transforms run after validation to shape data:

const UserCreateSchema = z.object({
  name: z.string().trim(), // trim whitespace
  email: z.string().email().toLowerCase(), // normalize email
  password: z.string().min(8).transform(async (pw) => bcrypt.hash(pw, 12)),
  birthDate: z.string().pipe(z.coerce.date()), // string → Date
});

Transforms integrate into the validation pipeline — the output type changes when transforms run. z.infer<typeof UserCreateSchema> reflects the transformed output, not the raw input.

Discriminated Unions for API Payloads

Discriminated unions are the right model for polymorphic API payloads — requests that take different shapes depending on a type field:

const PaymentSchema = z.discriminatedUnion('method', [
  z.object({
    method: z.literal('card'),
    card_number: z.string(),
    expiry: z.string(),
    cvv: z.string(),
  }),
  z.object({
    method: z.literal('bank_transfer'),
    account_number: z.string(),
    routing_number: z.string(),
  }),
  z.object({
    method: z.literal('crypto'),
    wallet_address: z.string(),
    currency: z.enum(['BTC', 'ETH', 'USDC']),
  }),
]);

type Payment = z.infer<typeof PaymentSchema>;
// TypeScript knows: if method === 'card', card_number exists

Zod optimizes discriminatedUnion by checking the discriminant field first before trying each variant — much faster than z.union() for large schemas.

parse vs safeParse

z.parse() throws on validation failure. z.safeParse() returns a result object. For request validation in API handlers, always use safeParse:

// ❌ parse throws — uncaught in async handlers
app.post('/users', async (req, res) => {
  const data = CreateUserSchema.parse(req.body); // throws if invalid
});

// ✅ safeParse returns { success: true, data } or { success: false, error }
app.post('/users', async (req, res) => {
  const result = CreateUserSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({
      error: 'validation_error',
      issues: result.error.issues,
    });
  }
  const user = await db.users.create(result.data);
  return res.json(user);
});

z.parseAsync() and z.safeParseAsync() are the async variants for schemas with async transforms (like the bcrypt example above).

OpenAPI Generation from TypeScript

For public APIs, you need an OpenAPI spec. Writing YAML by hand is error-prone and gets stale. Generate it from your Zod schemas instead.

Zod to OpenAPI

The @asteasolutions/zod-to-openapi library (also known as zod-openapi) lets you register Zod schemas as OpenAPI components and generate a complete spec:

import { OpenAPIRegistry, OpenApiGeneratorV31 } from '@asteasolutions/zod-to-openapi';

const registry = new OpenAPIRegistry();

const UserSchema = registry.register(
  'User',
  z.object({
    id: z.string().uuid().openapi({ example: 'usr_abc123' }),
    name: z.string().openapi({ example: 'Alice' }),
    email: z.string().email().openapi({ example: 'alice@example.com' }),
  })
);

registry.registerPath({
  method: 'post',
  path: '/users',
  summary: 'Create a new user',
  request: {
    body: {
      content: { 'application/json': { schema: CreateUserSchema } },
    },
  },
  responses: {
    201: {
      description: 'User created',
      content: { 'application/json': { schema: UserSchema } },
    },
    400: { description: 'Validation error' },
  },
});

const generator = new OpenApiGeneratorV31(registry.definitions);
const spec = generator.generateDocument({
  openapi: '3.1.0',
  info: { title: 'My API', version: '1.0.0' },
});

Hono + Zod OpenAPI

Hono has a first-class OpenAPI integration:

import { OpenAPIHono, createRoute } from '@hono/zod-openapi';

const app = new OpenAPIHono();

const createUserRoute = createRoute({
  method: 'post',
  path: '/users',
  request: {
    body: { content: { 'application/json': { schema: CreateUserSchema } } },
  },
  responses: {
    201: { content: { 'application/json': { schema: UserSchema } }, description: 'Created' },
  },
});

app.openapi(createUserRoute, async (c) => {
  const data = c.req.valid('json'); // Typed from CreateUserSchema
  const user = await db.users.create(data);
  return c.json(user, 201);
});

// Serve Swagger UI
app.doc('/doc', { openapi: '3.1.0', info: { title: 'API', version: '1' } });

Typed Clients from Spec

Once you have an OpenAPI spec, generate typed clients with orval or hey-api:

# orval
npx orval --config orval.config.ts

# hey-api
npx @hey-api/openapi-ts --input api.json --output src/client

Generated clients include typed request/response interfaces, React Query hooks, and fetch functions — keeping the frontend in sync with the backend without manual type maintenance.

Error Typing

Untyped errors are the dark matter of TypeScript APIs. The catch (e: any) pattern puts you back in JavaScript land.

Discriminated Union Error Types

Define all possible error types as a discriminated union:

type ApiError =
  | { code: 'validation_error'; issues: ZodIssue[] }
  | { code: 'not_found'; resource: string; id: string }
  | { code: 'unauthorized'; reason: string }
  | { code: 'rate_limited'; retry_after: number }
  | { code: 'internal_error'; request_id: string };

TypeScript can now exhaustively check that your error handlers cover all cases.

The Result Pattern

Rather than throwing errors, return a result type:

type Result<T, E = ApiError> =
  | { ok: true; value: T }
  | { ok: false; error: E };

async function createUser(data: CreateUser): Promise<Result<User>> {
  const existing = await db.users.findByEmail(data.email);
  if (existing) {
    return {
      ok: false,
      error: { code: 'validation_error', issues: [{ path: ['email'], message: 'Email already exists' }] },
    };
  }

  const user = await db.users.create(data);
  return { ok: true, value: user };
}

// Caller handles both cases explicitly
const result = await createUser(input);
if (!result.ok) {
  if (result.error.code === 'validation_error') {
    return res.status(400).json(result.error);
  }
}
return res.status(201).json(result.value);

Never-throw APIs are predictable and composable. The caller is forced to handle errors because they can't be accidentally swallowed by uncaught exception handlers.

For more on error response design, see API error handling and status codes.

Testing Type-Safe APIs

Testing tRPC Procedures

tRPC procedures can be tested directly without HTTP:

import { createCallerFactory } from '@trpc/server';
import { appRouter } from './router';

const createCaller = createCallerFactory(appRouter);

describe('user.create', () => {
  it('creates a user with valid input', async () => {
    const caller = createCaller({ session: mockSession });
    const user = await caller.user.create({
      name: 'Alice',
      email: 'alice@example.com',
    });
    expect(user.id).toBeDefined();
    expect(user.email).toBe('alice@example.com');
  });

  it('throws on duplicate email', async () => {
    await db.users.create({ name: 'Alice', email: 'alice@example.com' });
    const caller = createCaller({ session: mockSession });
    await expect(
      caller.user.create({ name: 'Alice2', email: 'alice@example.com' })
    ).rejects.toThrow();
  });
});

Calling procedures directly (bypassing HTTP) makes tests fast and removes the need for a test server.

Testing Zod Schemas

Validate your Zod schemas have the right behavior:

describe('CreateUserSchema', () => {
  it('accepts valid input', () => {
    const result = CreateUserSchema.safeParse({ name: 'Alice', email: 'alice@example.com' });
    expect(result.success).toBe(true);
  });

  it('rejects invalid email', () => {
    const result = CreateUserSchema.safeParse({ name: 'Alice', email: 'not-an-email' });
    expect(result.success).toBe(false);
    expect(result.error?.issues[0].path).toEqual(['email']);
  });

  it('rejects name longer than 100 chars', () => {
    const result = CreateUserSchema.safeParse({ name: 'A'.repeat(101), email: 'a@b.com' });
    expect(result.success).toBe(false);
  });
});

OpenAPI Spec Snapshot Testing

Prevent accidental breaking changes to your OpenAPI spec with snapshot tests:

it('OpenAPI spec matches snapshot', async () => {
  const spec = generateOpenAPISpec(); // Your spec generation function
  expect(JSON.stringify(spec, null, 2)).toMatchSnapshot();
});

When you change the spec intentionally, update the snapshot explicitly. This catches unintended API contract changes during code review.

For full API testing patterns including integration tests, see API testing strategies for 2026.

Drizzle vs Prisma in 2026

The choice between Drizzle and Prisma is the most debated topic in the TypeScript backend ecosystem. Both are excellent; the right choice depends on your constraints.

Drizzle: Lighter, Faster, SQL-First

Drizzle defines your schema in TypeScript files:

import { pgTable, text, uuid, timestamp } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
  createdAt: timestamp('created_at').defaultNow(),
});

export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;

Queries use a SQL-like API that maps closely to the generated SQL:

const users = await db
  .select()
  .from(usersTable)
  .where(eq(usersTable.email, email))
  .limit(1);

Drizzle advantages:

  • No Rust binary to compile (unlike Prisma's query engine)
  • Works in edge runtimes (Cloudflare Workers, Vercel Edge) without restrictions
  • Bundle size: ~50KB vs Prisma's ~2MB
  • Pure SQL queries — easier to optimize, easier to debug
  • Schema migrations via drizzle-kit are fast and predictable

Prisma: Richer Tooling, Better DX for Complex Domains

Prisma's schema language is more expressive for complex relations:

model Post {
  id        String    @id @default(uuid())
  author    User      @relation(fields: [authorId], references: [id])
  authorId  String
  tags      Tag[]
  comments  Comment[]
}

Relation queries are first-class:

const post = await prisma.post.findUnique({
  where: { id },
  include: { author: true, tags: true },
});

Prisma advantages:

  • Richer migration history and diff tooling (prisma migrate)
  • Prisma Studio — visual database browser
  • Better support for complex relations (many-to-many, self-referential)
  • More mature ecosystem (more Stack Overflow answers, more examples)
  • Prisma Accelerate for built-in connection pooling and caching

Which to Pick

Choose Drizzle if:

  • You're deploying to edge runtimes (Cloudflare Workers, Vercel Edge Functions)
  • Bundle size matters (serverless functions billed by cold start)
  • Your team is comfortable with SQL
  • You're starting a new project in 2026

Choose Prisma if:

  • You have a complex domain model with many relations
  • Your team prefers declarative schema syntax
  • You need Prisma Migrate's migration history tooling
  • You're migrating an existing Prisma codebase

Both ORMs provide excellent TypeScript types. The type safety story is equivalent. The decision is primarily about bundle size, edge compatibility, and schema syntax preference.

Runtime Validation: The Gap Between TypeScript and Production

TypeScript's type system is a compile-time guarantee — it cannot catch data that doesn't match your expected types at runtime. An external API response, a user-submitted form, or a database row with an unexpected null can all pass TypeScript's type checks if you're using type assertions or as casts, then fail at runtime in production.

The solution is runtime validation with a library like Zod, Valibot, or ArkType. These libraries let you define a schema once and use it to both validate data at runtime and infer a TypeScript type from that schema — eliminating the possibility of your runtime validator and your TypeScript types disagreeing. The pattern: define your schema with Zod, use z.infer<typeof mySchema> to derive the TypeScript type, and call mySchema.parse(data) whenever data enters your system from an untrusted source — API responses, form submissions, environment variables, database rows from dynamic queries.

For tRPC users, Zod is already built into the router definition — each procedure's input and output types are Zod schemas, so runtime validation and type inference happen automatically. For REST APIs built with Hono, Fastify, or Express, integrating Zod requires explicit parse calls at your route handlers. The overhead is worth it: runtime type errors in production are harder to debug than compile-time TypeScript errors, and they tend to surface in edge cases that didn't appear in testing. Treat the type system and runtime validation as complementary, not interchangeable — TypeScript catches mistakes during development; runtime validation catches malformed data in production.

For a broader look at type-safe API patterns, see API documentation with OpenAPI vs AsyncAPI for documenting the APIs you build.

The API Integration Checklist (Free PDF)

Step-by-step checklist: auth setup, rate limit handling, error codes, SDK evaluation, and pricing comparison for 50+ APIs. Used by 200+ developers.

Join 200+ developers. Unsubscribe in one click.