<!-- APIScout AI-readable guide source -->
<!-- Canonical: https://apiscout.dev/guides/building-apis-with-typescript-type-safe-2026 -->
<!-- Raw Markdown: https://apiscout.dev/guides/building-apis-with-typescript-type-safe-2026/raw.md -->
<!-- Source path: content/guides/building-apis-with-typescript-type-safe-2026.mdx -->

---
og_image: "/images/guides/building-apis-with-typescript-type-safe-2026.webp"
title: "Type-Safe APIs with TypeScript in 2026"
description: "Build fully type-safe APIs with TypeScript — tRPC, Zod validation, OpenAPI code generation, and end-to-end type inference from database to frontend. Guide 2026."
date: "2026-03-08"
author: "APIScout Team"
tags: ["typescript", "type-safe-api", "trpc", "zod", "api-development"]
tier: 1
---

# 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 Prisma** → **Zod** → **tRPC or Hono** → **React 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:

```prisma
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:

```typescript
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:

```typescript
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

```typescript
// 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

```typescript
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:

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

Then use with a typed fetch wrapper or generated client.

### React Query + Types

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

## The Full Pipeline

| Layer | Tool | Types |
|-------|------|-------|
| Database | Prisma / Drizzle | Generated from schema |
| Validation | Zod | Inferred from validators |
| API | tRPC / Hono | Inferred from procedures/routes |
| Client | tRPC client / openapi-typescript | Automatic / generated |
| UI | React + TypeScript | Flows 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:

```typescript
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:

```typescript
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:

```typescript
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`:

```typescript
// ❌ 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:

```typescript
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:

```typescript
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**:

```bash
# 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:

```typescript
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:

```typescript
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](/blog/api-error-handling-status-codes-2026).

## Testing Type-Safe APIs

### Testing tRPC Procedures

tRPC procedures can be tested directly without HTTP:

```typescript
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:

```typescript
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:

```typescript
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](/blog/api-testing-strategies-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:

```typescript
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:

```typescript
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:

```prisma
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:

```typescript
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](/blog/api-documentation-openapi-vs-asyncapi-2026) for documenting the APIs you build.
