Skip to content
Homeβ€Ί JavaScriptβ€Ί Full-Stack Type Safety in 2026 – The Ultimate Guide

Full-Stack Type Safety in 2026 – The Ultimate Guide

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: React.js β†’ Topic 46 of 47
End-to-end type safety across frontend, backend, database, and API layers using the latest tools and patterns.
πŸ”₯ Advanced β€” solid JavaScript foundation required
In this tutorial, you'll learn
End-to-end type safety across frontend, backend, database, and API layers using the latest tools and patterns.
  • Full-stack type safety means one type change propagates from database to UI without manual sync β€” the compiler is the integration test
  • The database schema is the canonical source of truth β€” infer all types from it using Drizzle's $inferSelect and $inferInsert, never write them manually
  • Drizzle types numeric as string and timestamp as Date β€” handle both explicitly in output Zod schemas using coercedDateToISOString
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑Quick Answer
  • Full-stack type safety means a single type change propagates from database schema to UI component without manual sync
  • The stack: Drizzle ORM (DB) β†’ drizzle-zod (validation) β†’ tRPC (API) β†’ TypeScript inference (frontend)
  • Shared type packages in monorepos are the structural foundation β€” without them, every layer defines its own types independently
  • tRPC eliminates codegen by inferring types from server procedures directly into client calls
  • Branded types prevent accidental interchange of structurally identical domain values (UserId vs TransactionId)
  • Biggest mistake: type-safe API layer but untyped database queries β€” the chain breaks at the weakest link
🚨 START HERE
Type Safety Quick Debug Cheat Sheet
When type errors leak through your full-stack pipeline, run through this checklist.
🟑API response shape does not match frontend type definition
Immediate ActionCapture the actual API response and compare against the TypeScript interface
Commands
curl -s http://localhost:3000/api/transactions | jq '.' > actual-response.json
npx tsc --noEmit 2>&1 | grep -i 'type\|interface\|Property'
Fix NowAdd a Zod output schema to the API procedure β€” tRPC will validate the return shape before sending and surface the mismatch at the server
🟑Drizzle types do not match actual query results after schema change
Immediate ActionRegenerate Drizzle types and restart the TypeScript language server
Commands
npx drizzle-kit generate
npx tsc --noEmit 2>&1 | grep -i 'schema\|column\|table'
Fix NowAfter running drizzle-kit generate, restart your editor's TypeScript server (VS Code: Cmd+Shift+P β†’ TypeScript: Restart TS Server)
🟑Date objects from database failing Zod string validation
Immediate ActionCheck whether your output schema uses z.string().datetime() or z.coerce.date()
Commands
grep -rn 'datetime\|coerce' packages/api/
node -e "const d = new Date(); console.log(typeof d, d instanceof Date, JSON.stringify(d))"
Fix NowAdd .transform((d) => (d instanceof Date ? d.toISOString() : d)) to timestamp fields in your output Zod schema, or use z.coerce.date() if the frontend expects Date objects
🟑Monorepo type imports resolve to different copies of the same type
Immediate ActionCheck for duplicate installations of the shared package
Commands
find . -path '*/node_modules/@company/shared/package.json' | grep -v '/.git/'
cat pnpm-workspace.yaml
Fix NowEnsure the shared package is listed in workspace dependencies using the workspace: protocol (workspace:*), not installed from a registry
Production IncidentThe Type Mismatch That Cost $2.3 Million in Failed TransactionsA payments platform added a currency_precision field to their database schema. The API returned it as a number. The frontend interpreted it differently. Three days later, $2.3 million in transactions were processed with incorrect rounding.
SymptomCustomer support tickets reporting incorrect charge amounts. All amounts were off by factors of 10 or 100. The error was consistent but not immediately obvious β€” small transactions appeared correct, large ones were clearly wrong.
AssumptionThe team assumed the database migration was safe because the API layer did not throw errors. They did not realize the API was returning currency_precision: 100 (the raw integer from the database) while the frontend expected currency_precision: 2 (decimal places to display). No type error was thrown because both sides typed the field as number β€” structurally identical, semantically incompatible.
Root causeThe database schema used an integer column for precision. The API typed it as number without semantic validation. The frontend assumed a different semantic meaning for the same field. There was no shared type definition β€” each layer defined its own understanding of currency_precision independently, and all three definitions were structurally valid TypeScript.
FixCreated a shared type package with a branded type: type CurrencyPrecision = number & { readonly __brand: 'CurrencyPrecision' }. Added a Zod validator that enforces semantic constraints (precision must be an integer between 0 and 8). The constructor function validates and casts. The frontend imports the same branded type β€” a change to the semantic contract now produces a compile error if any layer handles it incorrectly.
Key Lesson
Primitive types like number and string carry no semantic meaning β€” use branded types for domain conceptsThe API boundary is where server and client contracts meet β€” it is the most critical validation pointShared type packages eliminate the gap where each layer reinterprets the same data differentlyA type system that allows number where CurrencyPrecision is expected is not type-safe β€” it is type-adjacent
Production Debug GuideWhen type mismatches leak through your pipeline
Frontend receives data that does not match its TypeScript types — runtime errors on property access→The API response shape has drifted from the frontend type. Capture the actual API response with curl, compare it against the TypeScript interface manually. Add Zod output validation to the API procedure to catch this drift automatically on the next occurrence.
Database query returns Date objects but Zod schema expects strings — validateOutput throws on every query→This is the Date serialization gap. Drizzle returns timestamp columns as Date objects. JSON serialization converts them to strings. Your Zod output schema must use z.coerce.date() if you want Date objects, or z.string().datetime() with a .transform() that converts Date to ISO string before validation. Add the transform explicitly — do not assume the serialization layer handles it.
Drizzle or Prisma types do not match actual query results after a migration→Run npx drizzle-kit generate to regenerate the migration files, then restart your TypeScript language server. For Prisma, run npx prisma generate. The type mismatch usually means the schema file was updated but the generated client was not regenerated.
tRPC procedure compiles but returns incorrect data shape to the client→The procedure's return type was inferred from a transformation function that lost type information. Add an explicit .output() Zod schema to the tRPC procedure. tRPC will validate the return value against the schema at runtime and throw before sending malformed data to the client.
Shared type package causes circular dependency errors in monorepo builds→The shared package is importing from layer-specific packages (React, Express, Prisma). The shared package must have zero framework dependencies. Restructure: shared package contains only primitive domain types and API contract types. Layer-specific types belong in their respective packages.
CI passes but production throws TypeError — works in development→Development uses relaxed TypeScript settings, mocked data, or a tsconfig that does not match production. Verify tsconfig.json has strict: true in every package. Run npx tsc --noEmit as a separate CI step from the build. Ensure the CI environment uses the same Node.js version as production.

Full-stack type safety has moved from a nice-to-have to a production requirement. The cost of runtime type errors β€” broken API contracts, mismatched database schemas, stale frontend interfaces β€” compounds silently until a deployment triggers a cascade of failures.

The 2026 tooling landscape has consolidated around a clear pipeline: the database schema defines the source of truth, validation libraries enforce boundaries at runtime, and inference-heavy API layers eliminate codegen entirely. Teams that connect these layers with shared type packages eliminate an entire category of bugs that integration tests were previously needed to catch.

This guide covers the architecture, tools, and production-tested patterns for achieving end-to-end type safety across all four layers: database, validation, API, and frontend. It also covers incremental migration for teams that already have a partially typed stack, error propagation across the chain, and the Date-to-string serialization gap that silently breaks most implementations.

Layer 1: Database Schema as the Source of Truth

Full-stack type safety starts at the database. The schema defines the canonical shape of your data. Every downstream layer β€” validation, API, frontend β€” must derive its types from this source, never define them independently.

Drizzle ORM is the dominant choice for type-safe database access in 2026. Unlike Prisma, which generates types in a separate codegen step that introduces a synchronization gap, Drizzle infers types directly from the schema definition. The moment you update the schema file, the types update β€” no generate command, no restart.

The critical pattern: define your schema once, infer types everywhere. Never manually write TypeScript interfaces that mirror your database schema. The moment you do, you have two sources of truth that will drift.

Drizzle's numeric columns (used for currency) deserve special attention: Drizzle types numeric as string in TypeScript, not number. This is correct β€” JavaScript cannot represent large decimal values with float precision. Your Zod schemas and application code must handle this.

packages/db/schema.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
// packages/db/schema.ts
// Database schema β€” the single source of truth for all types in the system.
// All downstream types are inferred from here. Nothing is manually mirrored.

import {
  pgTable,
  serial,
  text,
  integer,
  timestamp,
  boolean,
  numeric,
  index,
} from 'drizzle-orm/pg-core';
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
import { z } from 'zod';

// ---------------------------------------------------------------
// Table definitions
// ---------------------------------------------------------------

export const users = pgTable(
  'users',
  {
    id: serial('id').primaryKey(),
    email: text('email').notNull().unique(),
    name: text('name').notNull(),
    // Stored as integer: 2 means 2 decimal places (e.g., USD cents display)
    currencyPrecision: integer('currency_precision').notNull().default(2),
    isActive: boolean('is_active').notNull().default(true),
    createdAt: timestamp('created_at').notNull().defaultNow(),
    updatedAt: timestamp('updated_at').notNull().defaultNow(),
  },
  (table) => ({
    emailIdx: index('users_email_idx').on(table.email),
  })
);

export const transactions = pgTable(
  'transactions',
  {
    id: serial('id').primaryKey(),
    userId: integer('user_id')
      .notNull()
      .references(() => users.id),
    // numeric columns are typed as string by Drizzle β€” correct for financial values
    // JavaScript floats cannot represent large decimals precisely
    amount: numeric('amount', { precision: 12, scale: 2 }).notNull(),
    currency: text('currency').notNull().default('USD'),
    status: text('status', {
      enum: ['pending', 'completed', 'failed', 'refunded'],
    }).notNull(),
    metadata: text('metadata'), // JSON stored as text
    createdAt: timestamp('created_at').notNull().defaultNow(),
  },
  (table) => ({
    userIdIdx: index('transactions_user_id_idx').on(table.userId),
    statusIdx: index('transactions_status_idx').on(table.status),
  })
);

// ---------------------------------------------------------------
// Inferred types β€” derived from schema, never manually written
// ---------------------------------------------------------------

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

// Transaction['amount'] is string β€” Drizzle types numeric as string
// Transaction['createdAt'] is Date β€” Drizzle types timestamp as Date
// These differ from their JSON-serialized forms and must be handled
// explicitly in the API output layer (see Layer 2)

// ---------------------------------------------------------------
// Zod schemas derived from Drizzle schema via drizzle-zod
// drizzle-zod ^0.5 β€” refinements receive a Zod field schema
// and return an augmented schema
// ---------------------------------------------------------------

export const userSelectSchema = createSelectSchema(users, {
  // createdAt and updatedAt are Date objects from Drizzle
  // No refinement needed here β€” the select schema reflects the DB reality
});

export const userInsertSchema = createInsertSchema(users, {
  email: (schema) => schema.email('Must be a valid email address'),
  name: (schema) => schema.min(1, 'Name is required').max(255, 'Name too long'),
  // currencyPrecision defaults to 2 β€” constrain to valid range
  currencyPrecision: (schema) =>
    schema
      .int('Must be an integer')
      .min(0, 'Precision cannot be negative')
      .max(8, 'Precision cannot exceed 8'),
});

export const transactionSelectSchema = createSelectSchema(transactions);

export const transactionInsertSchema = createInsertSchema(transactions, {
  // amount is string (numeric column) β€” validate as decimal string
  amount: () =>
    z
      .string()
      .regex(/^\d+(\.\d{1,2})?$/, 'Amount must be a valid decimal with up to 2 places'),
  currency: (schema) =>
    schema.length(3, 'Currency must be a 3-character ISO 4217 code').toUpperCase(),
});

// ---------------------------------------------------------------
// Branded types β€” semantic safety beyond primitive types
//
// Branded types prevent accidental interchange of structurally
// identical values. UserId and TransactionId are both number,
// but TypeScript treats them as incompatible types.
//
// The unique symbol brand is stronger than string brands:
// it cannot be forged with `42 as UserId` from outside this module
// because the symbol is not exported.
// ---------------------------------------------------------------

declare const UserIdBrand: unique symbol;
declare const TransactionIdBrand: unique symbol;
declare const CurrencyCodeBrand: unique symbol;
declare const CurrencyPrecisionBrand: unique symbol;

export type UserId = number & { readonly [UserIdBrand]: typeof UserIdBrand };
export type TransactionId = number & {
  readonly [TransactionIdBrand]: typeof TransactionIdBrand;
};
export type CurrencyCode = string & {
  readonly [CurrencyCodeBrand]: typeof CurrencyCodeBrand;
};
export type CurrencyPrecision = number & {
  readonly [CurrencyPrecisionBrand]: typeof CurrencyPrecisionBrand;
};

// Constructor functions validate before casting.
// These are the only places in the codebase where casting to branded types is allowed.

export function toUserId(id: number): UserId {
  if (!Number.isInteger(id) || id <= 0) {
    throw new Error(`Invalid UserId: ${id} β€” must be a positive integer`);
  }
  return id as unknown as UserId;
}

export function toTransactionId(id: number): TransactionId {
  if (!Number.isInteger(id) || id <= 0) {
    throw new Error(`Invalid TransactionId: ${id} β€” must be a positive integer`);
  }
  return id as unknown as TransactionId;
}

export function toCurrencyCode(code: string): CurrencyCode {
  if (!/^[A-Z]{3}$/.test(code)) {
    throw new Error(
      `Invalid CurrencyCode: "${code}" β€” must be a 3-letter uppercase ISO 4217 code`
    );
  }
  return code as unknown as CurrencyCode;
}

export function toCurrencyPrecision(precision: number): CurrencyPrecision {
  if (!Number.isInteger(precision) || precision < 0 || precision > 8) {
    throw new Error(
      `Invalid CurrencyPrecision: ${precision} β€” must be an integer between 0 and 8`
    );
  }
  return precision as unknown as CurrencyPrecision;
}
Mental Model
The Single Source of Truth Principle
If you manually write a TypeScript interface that mirrors your database schema, you have already failed β€” you now have two sources of truth that will drift apart silently.
  • The database schema IS the source of truth β€” everything else derives from it
  • Drizzle infers types directly from the schema definition β€” no codegen step, no sync gap
  • Drizzle types numeric columns as string, not number β€” this is intentional and correct for financial values
  • Drizzle types timestamp columns as Date objects β€” JSON serialization converts them to strings, which must be handled explicitly in the API layer
  • Branded types with unique symbol are stronger than string brands β€” they cannot be forged via type assertion outside the constructor module
πŸ“Š Production Insight
A team manually maintained TypeScript interfaces mirroring their Prisma schema.
After 6 months, 23% of interfaces had drifted from the actual database schema.
Runtime errors appeared only in production when specific query paths were exercised.
The errors were silent in development because development data never exercised the drifted fields.
Rule: if your types are not inferred from the schema, they are already wrong.
🎯 Key Takeaway
The database schema is the canonical source of truth for all types.
Infer types from the schema β€” never manually write interfaces that mirror it.
Drizzle types numeric as string and timestamp as Date β€” handle both explicitly in your validation layer.
Use unique symbol branded types for domain concepts β€” they cannot be forged outside the constructor.

Layer 2: Validation at Every Boundary

TypeScript types are erased at compile time β€” they do not exist at runtime. A perfectly typed codebase can still receive malformed data from external sources (API requests, database results, third-party webhooks) without any compile-time warning. Types tell you what you expect. Zod tells you what you actually got.

Zod bridges this gap by providing runtime validation that also produces TypeScript types. Define a Zod schema once and you get both runtime validation and compile-time type inference. The pattern: validate at every boundary where untrusted data enters or exits your system.

The four critical boundaries in a full-stack application: (1) API input β€” every request body, query parameter, and path parameter. (2) API output β€” every response before sending, to catch internal type drift. (3) Database results β€” after queries, to catch migration drift. (4) External sources β€” every third-party webhook, partner API response, and environment variable.

The Date serialization gap is the most common silent failure in Drizzle-based pipelines. Drizzle returns timestamp columns as JavaScript Date objects. JSON.stringify converts Date objects to ISO strings. Your Zod output schema must account for this conversion explicitly β€” otherwise validateOutput will fail on every database result that contains a timestamp.

packages/api/validation.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
// packages/api/validation.ts
// Boundary validation for all four critical boundaries.
//
// The Date serialization gap:
// Drizzle returns timestamp columns as Date objects.
// JSON serialization converts Date to ISO string.
// Output Zod schemas must handle both forms β€” the field
// arrives as Date from the DB, but leaves as string in the JSON response.
// The coercedDateToISOString helper below handles this explicitly.

import { z } from 'zod';
import {
  toUserId,
  toTransactionId,
  toCurrencyCode,
  type UserId,
  type TransactionId,
  type CurrencyCode,
} from '@company/db';

// ---------------------------------------------------------------
// Date serialization helper
// Use this for every timestamp field in output schemas.
// Accepts a Date object (from Drizzle) or ISO string (already serialized)
// and always produces an ISO string for the JSON response.
// ---------------------------------------------------------------

export const coercedDateToISOString = z
  .union([
    z.date().transform((d) => d.toISOString()),
    z.string().datetime(),
  ])
  .describe('Timestamp β€” accepts Date or ISO string, always outputs ISO string');

// ---------------------------------------------------------------
// BOUNDARY 1: API input validation
// Validates data arriving from the client before it touches the database.
// .transform() casts validated primitives to branded types.
// ---------------------------------------------------------------

export const CreateTransactionInput = z.object({
  // Transform validated integer to branded UserId
  userId: z
    .number()
    .int('User ID must be an integer')
    .positive('User ID must be positive')
    .transform(toUserId),
  // amount arrives as a number from the client form
  // We convert to string for storage (numeric column)
  amount: z
    .number()
    .positive('Amount must be positive')
    .multipleOf(0.01, 'Amount cannot have more than 2 decimal places')
    .transform((n) => n.toFixed(2)),
  // Transform and validate ISO 4217 currency code
  currency: z
    .string()
    .length(3, 'Currency must be a 3-character ISO 4217 code')
    .transform((s) => s.toUpperCase())
    .transform(toCurrencyCode),
  metadata: z.record(z.string(), z.unknown()).optional(),
});

export type CreateTransactionInput = z.infer<typeof CreateTransactionInput>;
// CreateTransactionInput.userId is UserId (branded)
// CreateTransactionInput.currency is CurrencyCode (branded)
// CreateTransactionInput.amount is string (converted for storage)

export const GetTransactionInput = z.object({
  id: z
    .number()
    .int('Transaction ID must be an integer')
    .positive('Transaction ID must be positive')
    .transform(toTransactionId),
});

export type GetTransactionInput = z.infer<typeof GetTransactionInput>;

export const ListTransactionsInput = z.object({
  limit: z.number().int().min(1).max(100).default(20),
  cursor: z.number().int().positive().optional(),
  status: z
    .enum(['pending', 'completed', 'failed', 'refunded'])
    .optional(),
});

export type ListTransactionsInput = z.infer<typeof ListTransactionsInput>;

// ---------------------------------------------------------------
// BOUNDARY 2: API output validation
// Validates data leaving the server before it reaches the client.
// This catches internal drift: schema changes, transformation bugs,
// nullable columns added without updating types.
//
// Critical: timestamp fields use coercedDateToISOString to handle
// the Date-to-string gap between Drizzle and JSON serialization.
// ---------------------------------------------------------------

export const TransactionResponse = z.object({
  id: z.number().int().positive(),
  userId: z.number().int().positive(),
  // amount is stored as numeric (string in Drizzle) β€” validate as decimal string
  amount: z
    .string()
    .regex(/^\d+\.\d{2}$/, 'Amount must be a decimal string with 2 places'),
  currency: z.string().length(3),
  status: z.enum(['pending', 'completed', 'failed', 'refunded']),
  // createdAt: accepts Date from Drizzle OR ISO string if already serialized
  createdAt: coercedDateToISOString,
});

export type TransactionResponse = z.infer<typeof TransactionResponse>;
// TransactionResponse.createdAt is string (ISO 8601)
// TransactionResponse.amount is string (decimal)
// These are the shapes the frontend will receive

export const UserResponse = z.object({
  id: z.number().int().positive(),
  email: z.string().email(),
  name: z.string().min(1),
  currencyPrecision: z
    .number()
    .int()
    .min(0)
    .max(8),
  isActive: z.boolean(),
  createdAt: coercedDateToISOString,
  updatedAt: coercedDateToISOString,
});

export type UserResponse = z.infer<typeof UserResponse>;

export const PaginatedTransactionsResponse = z.object({
  items: z.array(TransactionResponse),
  nextCursor: z.number().int().positive().nullable(),
});

export type PaginatedTransactionsResponse = z.infer<
  typeof PaginatedTransactionsResponse
>;

// ---------------------------------------------------------------
// Validation utility functions
// Use validateOutput on every response before returning from a procedure.
// Use validateDbRow when you need to validate individual query results.
// ---------------------------------------------------------------

export function validateOutput<T>(
  schema: z.ZodSchema<T>,
  data: unknown,
  context: string
): T {
  const result = schema.safeParse(data);
  if (!result.success) {
    // Log the full issue list for debugging β€” do not swallow the error
    console.error(
      `[TYPE DRIFT] Output validation failed in ${context}:`,
      result.error.issues
    );
    throw new Error(
      `Internal type drift detected in ${context} β€” check server logs`
    );
  }
  return result.data;
}

export function validateDbRow<T>(
  schema: z.ZodSchema<T>,
  row: unknown,
  context: string
): T {
  const result = schema.safeParse(row);
  if (!result.success) {
    console.error(
      `[DB DRIFT] Row does not match expected schema in ${context}:`,
      result.error.issues
    );
    throw new Error(
      `Database result does not match expected schema in ${context}`
    );
  }
  return result.data;
}

// ---------------------------------------------------------------
// BOUNDARY 3: External webhook validation
// Every third-party payload must be validated against an explicit schema.
// Never trust external data β€” validate before processing.
// ---------------------------------------------------------------

export const StripeWebhookPayload = z.object({
  id: z.string().startsWith('evt_', 'Stripe event IDs start with evt_'),
  type: z.string().min(1),
  data: z.object({
    object: z.object({
      id: z.string().startsWith('pi_', 'Payment intent IDs start with pi_'),
      amount: z
        .number()
        .int('Stripe amounts are integers in the smallest currency unit'),
      currency: z.string().length(3),
      status: z.enum(['succeeded', 'processing', 'requires_payment_method', 'canceled']),
    }),
  }),
  created: z.number().int(),
});

export type StripeWebhookPayload = z.infer<typeof StripeWebhookPayload>;

// ---------------------------------------------------------------
// BOUNDARY 4: Environment variable validation
// Validate process.env at application startup, not at call time.
// This catches missing configuration before the server accepts requests.
// ---------------------------------------------------------------

export const ServerEnvSchema = z.object({
  DATABASE_URL: z.string().url('DATABASE_URL must be a valid connection URL'),
  NODE_ENV: z.enum(['development', 'staging', 'production']),
  PORT: z
    .string()
    .transform(Number)
    .pipe(z.number().int().min(1024).max(65535))
    .default('3000'),
});

export type ServerEnv = z.infer<typeof ServerEnvSchema>;

// Call this at server startup β€” throws if any env var is missing or invalid
export function validateServerEnv(): ServerEnv {
  const result = ServerEnvSchema.safeParse(process.env);
  if (!result.success) {
    console.error('[ENV] Invalid server configuration:', result.error.issues);
    throw new Error('Server environment validation failed β€” see above for details');
  }
  return result.data;
}
Mental Model
The Date Serialization Gap
Drizzle returns Date objects. JSON.stringify converts them to strings. Your Zod output schema sees the raw Drizzle value β€” not the serialized string. Without explicit handling, every query with a timestamp column will fail output validation.
  • Drizzle types timestamp columns as Date objects in TypeScript
  • JSON serialization (res.json(), tRPC's serializer) converts Date to ISO string automatically
  • Your Zod output schema runs before serialization β€” it sees the Date object, not the string
  • Use z.union([z.date().transform(d => d.toISOString()), z.string().datetime()]) to accept both forms
  • The frontend always receives a string β€” define TransactionResponse.createdAt as string, not Date
πŸ“Š Production Insight
A team added Zod output validation to all tRPC procedures.
Every procedure with a createdAt field started throwing immediately β€” all output validation failed.
The cause: Drizzle returns Date objects, the Zod output schema used z.string().datetime().
tRPC had not serialized the response yet when the output schema ran β€” it was validating a Date with a string schema.
Rule: output schemas must handle the Drizzle-native types (Date, string for numeric), not the JSON-serialized forms.
🎯 Key Takeaway
TypeScript types are compile-time only β€” Zod provides the runtime validation layer.
Validate at all four boundaries: API input, API output, database results, external sources.
The Date serialization gap is the most common Drizzle pipeline failure β€” handle it explicitly in every output schema.
validateOutput() should log full Zod issue details to monitoring β€” silent failures hide migration drift.

Layer 3: Type-Safe API Layer with tRPC

tRPC eliminates the most fragile part of the type safety chain: the API contract. Traditional REST APIs require either manual type definitions that drift or codegen steps that go stale. tRPC infers types directly from server procedures into client calls β€” no codegen, no sync gap, no separate generate command.

Every tRPC procedure must define both an input schema and an output schema. The input schema validates incoming data. The output schema validates the return value before it leaves the server β€” this catches internal drift that TypeScript inference alone cannot detect.

Error handling is typed too. tRPC uses TRPCError to send structured errors. The frontend receives typed error codes and can narrow on them. Never throw raw Error objects from tRPC procedures β€” they reach the client as opaque INTERNAL_SERVER_ERROR responses.

For teams that need REST (public APIs, non-TypeScript clients), use ts-rest. ts-rest defines a shared contract (input and output schemas) that both client and server implement. The same Zod schemas can be reused β€” the validation logic is identical regardless of the transport layer.

packages/api/routers/transaction.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
// packages/api/routers/transaction.ts
// Type-safe tRPC router with input validation, output validation,
// and typed error handling.
//
// Every procedure defines:
//   .input()  β€” validates and types incoming data
//   .output() β€” validates return value before sending to client
//
// .output() does two things:
//   1. Infers the return type for the client (compile-time)
//   2. Validates the actual return value at runtime (catches internal drift)

import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { eq, desc, lt, and } from 'drizzle-orm';
import { router, protectedProcedure } from '../trpc';
import { transactions } from '@company/db';
import {
  CreateTransactionInput,
  GetTransactionInput,
  ListTransactionsInput,
  TransactionResponse,
  PaginatedTransactionsResponse,
  validateOutput,
} from '../validation';

export const transactionRouter = router({
  // -----------------------------------------------------------------
  // getById: fetch a single transaction by ID
  // -----------------------------------------------------------------
  getById: protectedProcedure
    .input(GetTransactionInput)
    .output(TransactionResponse)
    .query(async ({ input, ctx }) => {
      const rows = await ctx.db
        .select()
        .from(transactions)
        .where(eq(transactions.id, input.id))
        .limit(1);

      if (!rows[0]) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: `Transaction ${input.id} not found`,
        });
      }

      // validateOutput catches drift between the DB row shape and
      // what the client expects. coercedDateToISOString in
      // TransactionResponse handles the Date β†’ string conversion.
      return validateOutput(
        TransactionResponse,
        rows[0],
        'transactionRouter.getById'
      );
    }),

  // -----------------------------------------------------------------
  // create: insert a new transaction
  // -----------------------------------------------------------------
  create: protectedProcedure
    .input(CreateTransactionInput)
    .output(TransactionResponse)
    .mutation(async ({ input, ctx }) => {
      // Verify the user exists before creating the transaction
      const userExists = await ctx.db.query.users.findFirst({
        where: (users, { eq }) => eq(users.id, input.userId),
        columns: { id: true },
      });

      if (!userExists) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: `User ${input.userId} does not exist`,
        });
      }

      const [created] = await ctx.db
        .insert(transactions)
        .values({
          userId: input.userId,
          // amount was transformed to string by the input schema
          amount: input.amount,
          // currency was validated and branded by the input schema
          currency: input.currency,
          status: 'pending',
          metadata: input.metadata
            ? JSON.stringify(input.metadata)
            : null,
        })
        .returning();

      if (!created) {
        throw new TRPCError({
          code: 'INTERNAL_SERVER_ERROR',
          message: 'Transaction insert did not return a row',
        });
      }

      return validateOutput(
        TransactionResponse,
        created,
        'transactionRouter.create'
      );
    }),

  // -----------------------------------------------------------------
  // list: paginated transaction list with optional status filter
  // -----------------------------------------------------------------
  list: protectedProcedure
    .input(ListTransactionsInput)
    .output(PaginatedTransactionsResponse)
    .query(async ({ input, ctx }) => {
      const conditions = [];

      if (input.status) {
        conditions.push(eq(transactions.status, input.status));
      }

      if (input.cursor) {
        conditions.push(lt(transactions.id, input.cursor));
      }

      // Fetch one extra row to determine if there is a next page
      const rows = await ctx.db
        .select()
        .from(transactions)
        .where(conditions.length > 0 ? and(...conditions) : undefined)
        .orderBy(desc(transactions.id))
        .limit(input.limit + 1);

      const hasNextPage = rows.length > input.limit;
      const pageRows = rows.slice(0, input.limit);

      return validateOutput(
        PaginatedTransactionsResponse,
        {
          items: pageRows.map((row) =>
            validateOutput(
              TransactionResponse,
              row,
              'transactionRouter.list.item'
            )
          ),
          nextCursor: hasNextPage
            ? pageRows[pageRows.length - 1]?.id ?? null
            : null,
        },
        'transactionRouter.list'
      );
    }),
});

// -----------------------------------------------------------------
// Typed error handling
//
// tRPC error codes map to HTTP status codes:
//   NOT_FOUND          β†’ 404
//   BAD_REQUEST        β†’ 400
//   UNAUTHORIZED       β†’ 401
//   FORBIDDEN          β†’ 403
//   INTERNAL_SERVER_ERROR β†’ 500
//
// The frontend receives typed error codes and can narrow on them:
//
//   const mutation = trpc.transaction.create.useMutation({
//     onError(error) {
//       if (error.data?.code === 'BAD_REQUEST') {
//         // Handle validation error
//       }
//     },
//   });
// -----------------------------------------------------------------
Mental Model
Why Both Input AND Output Schemas Are Mandatory
Input validation protects the server from the client. Output validation protects the client from the server. tRPC's type inference alone is not enough β€” it infers the return type from the function signature, not the actual runtime value.
  • .input() prevents malformed data from reaching your database β€” validate and reject at the boundary
  • .output() validates the actual return value before sending β€” catches internal drift TypeScript cannot see
  • TypeScript infers the return type from what the function returns β€” if the function returns a drifted shape, the inferred type is wrong
  • The .output() schema runs at runtime and catches drift that type inference misses
  • For REST APIs, call validateOutput() before res.json() β€” same protection, different transport
πŸ“Š Production Insight
A team used tRPC without output schemas on 30 procedures.
After a refactor changed 5 return shapes, the frontend compiled without errors.
tRPC had inferred the stale return types β€” they matched the old function signatures exactly.
Runtime crashes appeared because the actual data did not match the inferred types.
Fixing: added .output() schemas to all 30 procedures. The next refactor produced compile errors before any code shipped.
Rule: type inference and runtime validation solve different problems β€” you need both.
🎯 Key Takeaway
tRPC infers server types directly into client calls β€” no codegen, no sync gap.
Every procedure must define both .input() AND .output() Zod schemas.
Throw TRPCError with explicit codes β€” never raw Error objects from procedures.
For REST APIs, use ts-rest with the same Zod schemas.

Layer 4: Frontend Type Consumption

The frontend is the end of the type safety chain. It consumes types β€” it never defines them. Every type the frontend uses must be inferred from the API layer or imported from the shared package. A frontend developer who writes interface ApiResponse has created a second source of truth.

The tRPC client provides full autocomplete and type checking for every API call β€” input parameters, return type, and error codes. These types are inferred from the server procedures. When a server procedure's input or output schema changes, the frontend immediately shows a compile error.

For forms, use the same Zod schema that the server uses for input validation. This ensures client-side validation catches the same errors the server would reject. The field error shapes are also typed β€” no manual error type definitions.

The key rule for deriving component prop types: use TypeScript's utility types (Pick, Omit, Partial) to create component-specific types from the canonical type. Never re-declare the same fields in a component interface.

apps/web/features/transactions/transaction-list.tsx Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
// apps/web/features/transactions/transaction-list.tsx
// Frontend type consumption β€” all types inferred or imported.
// No manual API response interfaces.

'use client';

import { useState } from 'react';
import { trpc } from '@/lib/trpc';
// Types imported from the shared API package β€” never redefined here
import type { TransactionResponse } from '@company/api';
import { CreateTransactionInput } from '@company/api';

// ---------------------------------------------------------------
// WRONG: Manually defining API response types
// ---------------------------------------------------------------
// interface Transaction {        // ❌ Second source of truth
//   id: number;
//   amount: string;
//   currency: string;
//   status: string;
//   createdAt: string;
// }
//
// This drifts the moment the API changes. TypeScript will not warn you.
// ---------------------------------------------------------------

// ---------------------------------------------------------------
// CORRECT: Type inferred from tRPC client
// ---------------------------------------------------------------
// trpc.transaction.list.useQuery() returns data typed as:
//   { items: TransactionResponse[]; nextCursor: number | null } | undefined
// This type comes directly from the server's .output() schema.
// A schema change produces a compile error here automatically.

export function TransactionList() {
  const { data, isLoading, error } = trpc.transaction.list.useQuery({
    limit: 20,
  });

  if (isLoading) {
    return <div aria-busy="true">Loading transactions...</div>;
  }

  // error.data.code is typed β€” you can narrow on specific tRPC error codes
  if (error) {
    if (error.data?.code === 'UNAUTHORIZED') {
      return <div>Please sign in to view transactions.</div>;
    }
    return <div>Failed to load transactions: {error.message}</div>;
  }

  if (!data) return null;

  return (
    <ul>
      {data.items.map((transaction) => (
        <TransactionItem key={transaction.id} transaction={transaction} />
      ))}
    </ul>
  );
}

// ---------------------------------------------------------------
// Component props derived from canonical type using utility types.
// Never redeclare fields that exist on TransactionResponse.
// ---------------------------------------------------------------

interface TransactionItemProps {
  // Pick only the fields this component uses β€” not the full type
  transaction: Pick<
    TransactionResponse,
    'id' | 'amount' | 'currency' | 'status' | 'createdAt'
  >;
  onSelect?: (id: number) => void;
}

function TransactionItem({ transaction, onSelect }: TransactionItemProps) {
  // transaction.createdAt is string (ISO 8601) β€” defined by TransactionResponse
  // Parse to Date for display β€” do not assume the format, always parse explicitly
  const formattedDate = new Intl.DateTimeFormat('en-US', {
    dateStyle: 'medium',
  }).format(new Date(transaction.createdAt));

  const formattedAmount = new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: transaction.currency,
  }).format(parseFloat(transaction.amount));

  return (
    <li
      onClick={() => onSelect?.(transaction.id)}
      className="flex justify-between py-2 cursor-pointer"
    >
      <span>{formattedAmount}</span>
      <span>{transaction.status}</span>
      <time dateTime={transaction.createdAt}>{formattedDate}</time>
    </li>
  );
}

// ---------------------------------------------------------------
// Form: same Zod schema as the server β€” one definition, both sides.
// Client validation catches the same errors the server would reject.
// Field error types are inferred from the Zod schema.
// ---------------------------------------------------------------

type CreateTransactionFormState = {
  values: Partial<{
    userId: number;
    amount: number;
    currency: string;
    metadata?: Record<string, unknown>;
  }>;
  // z.ZodError.flatten() produces typed field errors
  // The type is inferred from the schema β€” no manual error interfaces
  errors: ReturnType<
    ReturnType<(typeof CreateTransactionInput)['safeParse']>['error'] extends infer E
      ? E extends z.ZodError
        ? typeof E.prototype.flatten
        : never
      : never
  > | null;
};

function CreateTransactionForm() {
  const [state, setState] = useState<CreateTransactionFormState>({
    values: {},
    errors: null,
  });

  const mutation = trpc.transaction.create.useMutation({
    onError(error) {
      // error.data.code is typed as a TRPCError code
      if (error.data?.code === 'BAD_REQUEST') {
        console.error('Validation error from server:', error.message);
      }
    },
  });

  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();

    // Client-side validation using the same schema as the server.
    // .safeParse() on the raw form values β€” these are pre-transform types.
    // Note: the schema will run .transform() β€” userId becomes UserId, etc.
    const result = CreateTransactionInput.safeParse(state.values);

    if (!result.success) {
      setState((prev) => ({
        ...prev,
        errors: result.error.flatten() as any,
      }));
      return;
    }

    // result.data is fully typed: CreateTransactionInput (post-transform)
    // userId is UserId (branded), currency is CurrencyCode (branded)
    mutation.mutate(result.data);
  }

  return (
    <form onSubmit={handleSubmit}>
      {state.errors?.fieldErrors.amount && (
        <p role="alert" className="text-red-600">
          {state.errors.fieldErrors.amount[0]}
        </p>
      )}
      {/* form fields */}
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Creating...' : 'Create Transaction'}
      </button>
    </form>
  );
}

// Re-export types consumers of this feature might need
export type { TransactionItemProps };
⚠ Never Define API Types in the Frontend
πŸ“Š Production Insight
A frontend team defined their own API response types for 40 endpoints.
After a backend refactor changed 8 response shapes, the frontend compiled without errors.
The manually defined types matched the old shapes β€” TypeScript saw no issue.
Runtime errors appeared in production because the actual data did not match the manual types.
Fix: deleted all manual API types, imported from the tRPC client and @company/api instead.
The next backend refactor produced 23 compile errors in the frontend β€” all caught before deployment.
Rule: if your frontend types are not inferred from the API, they are fiction.
🎯 Key Takeaway
The frontend consumes types β€” it never defines them.
Derive component props from canonical types using Pick, Omit, and Partial.
Form validation must use the same Zod schema as the server β€” one definition, both sides.
tRPC error codes are typed β€” use them for error handling, not message string matching.

Monorepo Architecture: Shared Types as the Structural Foundation

Shared types are the glue that connects all four layers. In a monorepo, the shared type package is the structural foundation that makes the full type chain possible. Without it, every layer defines its own types independently and drift is structurally inevitable.

The shared package must contain only the intersection of cross-layer types: domain types (branded types, domain enums), API contract types (pagination shapes, error envelopes), and utility types (generic helpers). It must have zero framework dependencies β€” no React imports, no Express imports, no Drizzle imports, no Prisma imports.

This constraint is not aesthetic β€” it is structural. If the shared package imports from React, then every package that uses the shared types must also have React as a dependency. This creates circular dependency chains that manifest as build failures and 4-minute CI times.

The package export map is critical for tree-shaking and type resolution. Each sub-path export must be configured explicitly.

packages/shared/index.ts Β· TYPESCRIPT
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
// packages/shared/index.ts
// The shared type package β€” the structural foundation of full-stack type safety.
//
// Rules for this package:
//   1. Zero framework dependencies (no React, no Express, no Drizzle, no Prisma)
//   2. No layer-specific types (no component props, no middleware types, no ORM queries)
//   3. Only types used by two or more layers belong here
//   4. This package is the INTERSECTION of cross-layer types β€” not the union of all types

// ---------------------------------------------------------------
// CATEGORY 1: Domain types (branded)
// These are used by every layer β€” DB, API, and frontend
// The unique symbol brand cannot be forged via type assertion
// ---------------------------------------------------------------

declare const UserIdBrand: unique symbol;
declare const TransactionIdBrand: unique symbol;
declare const CurrencyCodeBrand: unique symbol;
declare const CurrencyPrecisionBrand: unique symbol;

export type UserId = number & { readonly [UserIdBrand]: typeof UserIdBrand };
export type TransactionId = number & {
  readonly [TransactionIdBrand]: typeof TransactionIdBrand;
};
export type CurrencyCode = string & {
  readonly [CurrencyCodeBrand]: typeof CurrencyCodeBrand;
};
export type CurrencyPrecision = number & {
  readonly [CurrencyPrecisionBrand]: typeof CurrencyPrecisionBrand;
};

export type TransactionStatus =
  | 'pending'
  | 'completed'
  | 'failed'
  | 'refunded';

// ---------------------------------------------------------------
// CATEGORY 2: API contract types
// Used by both the API package (to produce) and the frontend (to consume)
// ---------------------------------------------------------------

export interface PaginatedResponse<T> {
  items: T[];
  nextCursor: number | null;
}

export interface ApiError {
  code: string;
  message: string;
  details?: Record<string, string[]>;
}

export type ApiResponse<T> =
  | { data: T; error: null }
  | { data: null; error: ApiError };

// ---------------------------------------------------------------
// CATEGORY 3: Utility types
// Generic helpers used across layers
// ---------------------------------------------------------------

// Make specific fields required on an otherwise-optional type
export type RequireFields<T, K extends keyof T> = T & Required<Pick<T, K>>;

// Strip branded type tags β€” useful for serialization and testing
export type Unbranded<T> = {
  [K in keyof T]: T[K] extends number & { [key: symbol]: unknown }
    ? number
    : T[K] extends string & { [key: symbol]: unknown }
    ? string
    : T[K];
};

// Recursive deep partial β€” useful for update operations
export type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

// Result type for operations that can fail with typed errors
export type Result<T, E = ApiError> =
  | { ok: true; value: T }
  | { ok: false; error: E };

export function ok<T>(value: T): Result<T> {
  return { ok: true, value };
}

export function err<E = ApiError>(error: E): Result<never, E> {
  return { ok: false, error };
}
πŸ’‘The Intersection Rule
  • The shared package contains the INTERSECTION of types used by multiple layers β€” not the union of all types
  • Zero framework dependencies β€” importing React or Drizzle from shared creates circular dependency chains
  • Domain types (UserId, CurrencyCode) belong here β€” every layer uses them
  • Database connection configuration belongs in the DB package β€” only the DB layer needs it
  • React component props belong in the frontend package β€” only the frontend uses them
  • If you are unsure whether a type belongs in shared, ask: do two or more layers need this exact type? If not, it does not belong here
πŸ“Š Production Insight
A team put ALL types in the shared package β€” 400+ interfaces including React prop types, Express middleware types, and Prisma query result types.
The shared package imported from React, Express, and Prisma.
Every package in the monorepo depended on every other package via the shared package.
Build times increased from 12 seconds to 4 minutes due to circular dependency resolution.
Deleting layer-specific types from shared and moving them to their respective packages cut build time back to 18 seconds.
Rule: the shared package must have zero framework dependencies β€” this is structural, not stylistic.
🎯 Key Takeaway
The shared package is the intersection of cross-layer types β€” domain types, API contracts, utility types.
Zero framework dependencies β€” no React, no Express, no Drizzle, no Prisma.
If a type is only used by one layer, it belongs in that layer's package, not shared.

Error Handling Across the Type Chain

A complete guide to full-stack type safety cannot stop at the happy path. Errors are data β€” and they must be typed, validated, and propagated with the same discipline as successful responses.

tRPC provides structured, typed errors via TRPCError. Error codes map to HTTP status codes and are typed on the client. The frontend can narrow on error.data.code to handle specific error conditions without string-matching on error messages.

Zod validation failures produce a ZodError with typed field-level issues. When validation fails at the API input boundary, tRPC automatically converts it to a BAD_REQUEST error with the Zod issue list. The frontend receives this as a typed error and can map field-level issues to form field errors without additional parsing.

The error propagation chain: Zod validation failure β†’ TRPCError (BAD_REQUEST) β†’ typed error on client β†’ field-level error display in form. Each step is typed. No string parsing, no manual error shaping.

packages/api/error-handling.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
// packages/api/error-handling.ts
// Typed error patterns for the full-stack type chain.
// Error codes, structured error responses, and client-side error handling.

import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { ApiError } from '@company/shared';

// ---------------------------------------------------------------
// Typed application error codes
// These extend TRPCError codes with domain-specific error types.
// ---------------------------------------------------------------

export const AppErrorCode = {
  // Resource errors
  NOT_FOUND: 'NOT_FOUND',
  ALREADY_EXISTS: 'ALREADY_EXISTS',
  // Authorization errors
  UNAUTHORIZED: 'UNAUTHORIZED',
  FORBIDDEN: 'FORBIDDEN',
  INSUFFICIENT_FUNDS: 'INSUFFICIENT_FUNDS',
  // Validation errors
  VALIDATION_ERROR: 'VALIDATION_ERROR',
  INVALID_CURRENCY: 'INVALID_CURRENCY',
  INVALID_AMOUNT: 'INVALID_AMOUNT',
  // System errors
  INTERNAL_ERROR: 'INTERNAL_ERROR',
  DATABASE_ERROR: 'DATABASE_ERROR',
  EXTERNAL_SERVICE_ERROR: 'EXTERNAL_SERVICE_ERROR',
} as const;

export type AppErrorCode = (typeof AppErrorCode)[keyof typeof AppErrorCode];

// ---------------------------------------------------------------
// Structured error factory
// Wraps TRPCError with a consistent shape the frontend can rely on.
// ---------------------------------------------------------------

export function createAppError(
  code: AppErrorCode,
  message: string,
  details?: Record<string, string[]>
): TRPCError {
  // Map domain codes to tRPC HTTP codes
  const trpcCode = (() => {
    switch (code) {
      case AppErrorCode.NOT_FOUND:
        return 'NOT_FOUND';
      case AppErrorCode.UNAUTHORIZED:
        return 'UNAUTHORIZED';
      case AppErrorCode.FORBIDDEN:
      case AppErrorCode.INSUFFICIENT_FUNDS:
        return 'FORBIDDEN';
      case AppErrorCode.VALIDATION_ERROR:
      case AppErrorCode.INVALID_CURRENCY:
      case AppErrorCode.INVALID_AMOUNT:
      case AppErrorCode.ALREADY_EXISTS:
        return 'BAD_REQUEST';
      default:
        return 'INTERNAL_SERVER_ERROR';
    }
  })();

  return new TRPCError({
    code: trpcCode,
    message,
    cause: details ? new Error(JSON.stringify(details)) : undefined,
  });
}

// ---------------------------------------------------------------
// Zod error β†’ field error map
// Use this to convert Zod validation failures to form field errors
// on the client side. The output type is explicitly mapped so the
// frontend can render field-level error messages without string parsing.
// ---------------------------------------------------------------

export function zodErrorToFieldErrors(
  error: z.ZodError
): Record<string, string[]> {
  const fieldErrors: Record<string, string[]> = {};
  for (const issue of error.issues) {
    const path = issue.path.join('.');
    const key = path || '_root';
    if (!fieldErrors[key]) {
      fieldErrors[key] = [];
    }
    fieldErrors[key].push(issue.message);
  }
  return fieldErrors;
}

// ---------------------------------------------------------------
// Client-side error utilities
// These run in the frontend β€” imported from @company/api
// ---------------------------------------------------------------

// Type guard for TRPCClientError
import type { TRPCClientError } from '@trpc/client';
import type { AppRouter } from '../root';

export function isTRPCError(
  error: unknown
): error is TRPCClientError<AppRouter> {
  return (
    error !== null &&
    typeof error === 'object' &&
    'data' in error &&
    'message' in error
  );
}

// Extract typed field errors from a tRPC validation error response
export function extractFieldErrors(
  error: TRPCClientError<AppRouter>
): Record<string, string[]> | null {
  if (error.data?.code !== 'BAD_REQUEST') return null;
  try {
    // tRPC serializes Zod issues in error.data.zodError
    const zodError = error.data?.zodError;
    if (!zodError) return null;
    return (zodError.fieldErrors as Record<string, string[]>) ?? null;
  } catch {
    return null;
  }
}

// ---------------------------------------------------------------
// Frontend usage example:
//
// const mutation = trpc.transaction.create.useMutation({
//   onError(error) {
//     if (isTRPCError(error)) {
//       const fieldErrors = extractFieldErrors(error);
//       if (fieldErrors) {
//         setFormErrors(fieldErrors); // Record<string, string[]>
//         return;
//       }
//       if (error.data?.code === 'INSUFFICIENT_FUNDS') {
//         showToast('Insufficient funds for this transaction');
//         return;
//       }
//     }
//   },
// });
// ---------------------------------------------------------------
Mental Model
Errors Are Data β€” Type Them Like Data
If you handle errors by matching on error.message strings, you have an untyped API boundary in your error handling layer.
  • TRPCError codes are typed on the client β€” narrow on error.data.code, not error.message
  • Zod validation failures produce typed field-level issues β€” tRPC exposes them in error.data.zodError
  • Map Zod issues to form field errors once, in a utility function β€” never parse error messages in components
  • Define an AppErrorCode enum for domain-specific errors β€” the frontend can import and narrow on these
  • The error propagation chain is fully typed: Zod failure β†’ TRPCError β†’ typed client error β†’ form field errors
πŸ“Š Production Insight
A team handled all API errors by matching on error.message strings.
After an API error message was reworded for clarity, three frontend error handlers silently stopped working.
Error messages are for humans. Error codes are for code.
Rule: never branch on error.message β€” always branch on error.code.
🎯 Key Takeaway
Errors are data β€” type them with the same discipline as successful responses.
tRPC error codes are typed on the client β€” use them for error handling logic.
Zod field errors propagate through tRPC β€” extract them with extractFieldErrors() for form rendering.
Define domain error codes as a const enum β€” the frontend imports and narrows on them.

Incremental Migration: Adopting Type Safety in an Existing Codebase

Most teams encounter full-stack type safety after they already have an existing codebase with untyped or partially typed API boundaries. A big-bang migration rewrites everything at once β€” it is slow, risky, and kills team buy-in when it blocks feature development for months.

The incremental approach adds type safety in phases, each of which delivers immediate value and can be deployed independently. Start where the bugs are most expensive β€” the API boundary β€” and work outward.

Phase 1 (Week 1): Add Zod validation at API input boundaries. This is the highest-leverage change β€” it catches malformed input immediately and produces typed request bodies. No schema changes, no new packages beyond Zod itself.

Phase 2 (Weeks 2–3): Replace manually maintained TypeScript interfaces with schema-inferred types. Start with the most-used API response types. Delete the manual interface, import the Zod-inferred type instead.

Phase 3 (Month 2): Adopt tRPC or ts-rest for new endpoints. Do not migrate existing REST endpoints all at once β€” add new features using tRPC and let the REST layer age out naturally.

Phase 4 (Ongoing): Add branded types for domain concepts that cross multiple layers. Start with the highest-risk values (IDs, currency codes, status enums).

scripts/audit-type-safety.sh Β· BASH
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
#!/usr/bin/env bash
# audit-type-safety.sh
# Run before starting migration to understand the current state of type safety.
# Produces a prioritized list of the riskiest untyped boundaries.

set -euo pipefail

echo "=================================================================="
echo " Full-Stack Type Safety Audit"
echo "=================================================================="
echo ""

# -----------------------------------------------------------------
# 1. Find manually defined API response interfaces (migration targets)
# These interfaces may have drifted from the actual API responses.
# -----------------------------------------------------------------
echo "--- Manually defined API interfaces (potential drift sources) ---"
grep -rn --include='*.ts' --include='*.tsx' \
  -E 'interface [A-Z][a-zA-Z]*(Response|Request|Payload|ApiResult)' \
  apps/ src/ \
  | sort \
  | head -30
echo ""

# -----------------------------------------------------------------
# 2. Find unvalidated JSON.parse calls (runtime type risk)
# Every JSON.parse without a Zod schema is a potential runtime error.
# -----------------------------------------------------------------
echo "--- Unvalidated JSON.parse calls (add Zod validation here) ---"
grep -rn --include='*.ts' --include='*.tsx' \
  'JSON\.parse' \
  apps/ src/ packages/ \
  | grep -v 'safeParse\|schema\.parse\|\.parse(' \
  | grep -v node_modules \
  | grep -v '\.test\.' \
  | head -20
echo ""

# -----------------------------------------------------------------
# 3. Find 'as unknown as' or forced type assertions (type safety bypasses)
# Forced assertions hide real type mismatches.
# -----------------------------------------------------------------
echo "--- Forced type assertions (potential type safety bypasses) ---"
grep -rn --include='*.ts' --include='*.tsx' \
  -E 'as unknown as|as any' \
  apps/ src/ packages/ \
  | grep -v node_modules \
  | grep -v '\.test\.' \
  | grep -v '// safe:' \
  | sort \
  | head -20
echo ""

# -----------------------------------------------------------------
# 4. Find fetch() calls without response type validation
# Every raw fetch() is a potential untyped API boundary.
# -----------------------------------------------------------------
echo "--- Raw fetch() calls without Zod parsing (migration priority) ---"
grep -rn --include='*.ts' --include='*.tsx' \
  'await fetch(' \
  apps/ src/ \
  | grep -v 'safeParse\|schema\.parse\|z\.object' \
  | grep -v node_modules \
  | head -20
echo ""

# -----------------------------------------------------------------
# 5. TypeScript strict mode status per package
# Packages without strict: true have hidden type errors.
# -----------------------------------------------------------------
echo "--- TypeScript strict mode status per tsconfig ---"
find . -name 'tsconfig.json' \
  -not -path '*/node_modules/*' \
  -not -path '*/.git/*' \
  | while read -r f; do
    strict=$(python3 -c "
import json, sys
try:
  d = json.load(open('$f'))
  print(d.get('compilerOptions', {}).get('strict', 'NOT SET'))
except: print('PARSE ERROR')
" 2>/dev/null)
    echo "  $f β†’ strict: $strict"
  done
echo ""

echo "=================================================================="
echo " Recommended migration order:"
echo "  1. Enable strict: true in all tsconfig.json files"
echo "  2. Add Zod validation to all fetch() calls above"
echo "  3. Replace manual API interfaces with Zod-inferred types"
echo "  4. Migrate high-traffic endpoints to tRPC"
echo "  5. Add branded types for IDs and domain values"
echo "=================================================================="
πŸ’‘Migration Phase Order Matters
  • Phase 1 first: Zod at API boundaries β€” this is the highest-leverage change and requires no schema changes
  • Enable strict: true in tsconfig before anything else β€” it surfaces hidden type errors you need to fix anyway
  • Migrate new endpoints to tRPC first β€” do not attempt to migrate all existing REST endpoints at once
  • Add branded types last β€” they require changes across multiple layers and should wait until the other phases stabilize
  • Add the no-restricted-syntax ESLint rule to ban as any after the migration β€” prevents regression
πŸ“Š Production Insight
A team attempted a full migration to tRPC across 80 REST endpoints in a single sprint.
They underestimated the cascade of changes required: router setup, context changes, client refactoring, error handling migration.
The migration branch lived for 6 weeks and accumulated 200+ merge conflicts.
The team that migrated 5 endpoints per sprint had zero merge conflicts and shipped the migration in 4 months.
Rule: incremental migration with bounded scope is the only migration that ships.
🎯 Key Takeaway
Enable strict: true first β€” it surfaces hidden type errors before any other migration work.
Phase 1: Zod at API input boundaries (highest leverage, lowest risk).
Phase 2: Replace manual interfaces with schema-inferred types.
Phase 3: Migrate new endpoints to tRPC β€” let REST age out naturally.
Phase 4: Add branded types for IDs and domain values.
Never migrate all existing endpoints at once.

The Complete Pipeline: Database to UI

The full type safety pipeline connects all four layers into a single chain. A change at any point propagates through the entire system. This section shows the complete flow from a database schema change to a frontend compile error β€” and makes the case that the TypeScript compiler is your integration test for data contracts.

The pipeline: Drizzle schema change β†’ inferred types update automatically β†’ drizzle-zod schemas update automatically β†’ tRPC procedure types update β†’ frontend client types update β†’ compile error if the frontend code does not handle the change.

This chain means that adding a required field to a database table produces a compile error in the frontend form that must include the field. No manual synchronization, no documentation to update, no runtime errors in production.

packages/pipeline/full-pipeline-example.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
// full-pipeline-example.ts
// Annotated walkthrough of the complete type safety pipeline.
// Shows how a single schema change propagates to a frontend compile error.

// =================================================================
// SCENARIO: Add a required `merchantId` field to transactions
// =================================================================

// -----------------------------------------------------------------
// STEP 1: Database schema change (packages/db/schema.ts)
// -----------------------------------------------------------------
//
// export const transactions = pgTable('transactions', {
//   id: serial('id').primaryKey(),
//   userId: integer('user_id').notNull().references(() => users.id),
//   amount: numeric('amount', { precision: 12, scale: 2 }).notNull(),
//   currency: text('currency').notNull().default('USD'),
//   status: text('status', { enum: ['pending', 'completed', 'failed', 'refunded'] }).notNull(),
//
//   // NEW FIELD β€” required, no default
//   merchantId: integer('merchant_id').notNull().references(() => merchants.id),
//
//   metadata: text('metadata'),
//   createdAt: timestamp('created_at').notNull().defaultNow(),
// });

// -----------------------------------------------------------------
// STEP 2: Inferred types update automatically
// No command to run β€” the type updates the moment the schema file changes.
// -----------------------------------------------------------------
//
// export type Transaction = typeof transactions.$inferSelect;
// Transaction now has: merchantId: number
// Transaction now requires merchantId in inserts: NewTransaction

// -----------------------------------------------------------------
// STEP 3: drizzle-zod schemas update automatically
// createInsertSchema reads the schema at build time.
// -----------------------------------------------------------------
//
// export const transactionInsertSchema = createInsertSchema(transactions, {
//   amount: () => z.string().regex(/^\d+(\.\d{1,2})?$/),
//   currency: (schema) => schema.length(3).toUpperCase(),
// });
// transactionInsertSchema now REQUIRES merchantId

// -----------------------------------------------------------------
// STEP 4: API input validation schema updates automatically
// CreateTransactionInput is built from transactionInsertSchema.
// -----------------------------------------------------------------
//
// export const CreateTransactionInput = z.object({
//   userId: z.number().int().positive().transform(toUserId),
//   amount: z.number().positive().transform((n) => n.toFixed(2)),
//   currency: z.string().length(3).transform(toCurrencyCode),
//   merchantId: z.number().int().positive(), // NEW β€” required
//   metadata: z.record(z.string(), z.unknown()).optional(),
// });

// -----------------------------------------------------------------
// STEP 5: tRPC procedure input type updates
// The procedure uses CreateTransactionInput β€” its type updates automatically.
// -----------------------------------------------------------------
//
// create: protectedProcedure
//   .input(CreateTransactionInput)  // merchantId is now required in input
//   .output(TransactionResponse)
//   .mutation(async ({ input, ctx }) => { ... })

// -----------------------------------------------------------------
// STEP 6: Frontend compile error
// The tRPC client reflects the updated procedure input type.
// Any call to create that omits merchantId produces a compile error.
// -----------------------------------------------------------------
//
// const mutation = trpc.transaction.create.useMutation();
//
// mutation.mutate({
//   userId: 1,
//   amount: 99.99,
//   currency: 'USD',
//   // ❌ COMPILE ERROR:
//   // Argument of type '{ userId: number; amount: number; currency: string; }'
//   // is not assignable to parameter of type 'CreateTransactionInput'.
//   // Property 'merchantId' is missing.
// });

// =================================================================
// RESULT
// One schema change β†’ automatic compile error in the frontend.
// No manual sync. No documentation to update. No runtime error in production.
// The TypeScript compiler IS your integration test for data contracts.
// =================================================================

// -----------------------------------------------------------------
// SAFE MIGRATION PATTERN for adding required fields
// Never add a required column without a default value in a single deploy.
// The type chain enforces correctness β€” the deployment sequence
// must match the migration pattern below.
// -----------------------------------------------------------------
//
// Step A: Deploy migration with column as nullable (or with DEFAULT)
//   ALTER TABLE transactions ADD COLUMN merchant_id INTEGER REFERENCES merchants(id);
//
// Step B: Deploy the application code that populates merchant_id
//   (the schema still allows NULL, so existing inserts don't break)
//
// Step C: Backfill existing rows
//   UPDATE transactions SET merchant_id = ... WHERE merchant_id IS NULL;
//
// Step D: Deploy migration that makes the column NOT NULL
//   ALTER TABLE transactions ALTER COLUMN merchant_id SET NOT NULL;
//
// Step E: Update the Drizzle schema to .notNull() β€” types now enforce the contract
//   merchantId: integer('merchant_id').notNull().references(() => merchants.id),
//
// This sequence ensures the type chain is never broken at runtime.
Mental Model
The Compiler as Integration Test
Full-stack type safety turns the TypeScript compiler into an integration test for data contracts. If it compiles, the contract is correct across all layers β€” without running a single test.
  • Schema change β†’ inferred types update β†’ Zod schemas update β†’ tRPC types update β†’ frontend compile error
  • No manual synchronization steps β€” the chain is fully automated via type inference and schema derivation
  • The compiler catches contract violations before they reach production
  • Adding a required field to the database produces a compile error in the frontend form β€” no runtime test needed
  • This replaces the class of integration tests that check API response shapes β€” the compiler does it for free
πŸ“Š Production Insight
A team had 200 integration tests checking API response shapes.
After adopting the full pipeline, they deleted 170 of them β€” the compiler caught every shape mismatch they were testing.
The remaining 30 tests check business logic (correct amounts, correct status transitions) β€” things the type system cannot verify.
Rule: if your integration test checks data shape, the type system should catch it instead. Integration tests should verify behavior, not structure.
🎯 Key Takeaway
The complete pipeline: schema β†’ inferred types β†’ Zod validation β†’ tRPC inference β†’ frontend types.
One schema change produces a compile error in the frontend if the contract breaks.
Always use a multi-step migration pattern for adding required columns β€” never add NOT NULL without a default in a single deploy.
The TypeScript compiler IS your integration test for data contracts β€” it replaces shape-checking tests.
πŸ—‚ Type-Safe API Approaches Compared
Choosing the right pattern for your stack and team
ApproachType InferenceCodegen RequiredMulti-Language SupportBest ForPrimary Weakness
tRPC v11Full β€” server procedures inferred directly into clientNo β€” inference at build timeNo β€” TypeScript onlyMonorepo TypeScript apps with shared frontend and backendCannot serve non-TypeScript clients β€” not suitable for public APIs
ts-restFull β€” shared contract inferred by both client and serverNo β€” contract file is the sourceNo β€” TypeScript onlyREST APIs that need TypeScript type safety without tRPC's RPC modelMore boilerplate than tRPC β€” contract file must be maintained manually
OpenAPI + codegenPartial β€” generated from spec, types can go staleYes β€” must regenerate after every spec changeYes β€” any languagePublic APIs, polyglot stacks, external partner integrationsSync gap between spec and code β€” stale types if codegen is not run in CI
GraphQL + codegenFull β€” schema to resolvers to client hooksYes β€” must regenerate after schema changesYes β€” any languageComplex data graphs with flexible client queries, polyglot stacksSchema-first overhead, N+1 risk, codegen toolchain complexity
ZodiosFull β€” client inferred from Zod-validated API definitionNoNo β€” TypeScript onlyREST APIs with Zod-first definitions in TypeScriptSignificantly reduced maintenance activity as of 2025 β€” evaluate ts-rest as an alternative

🎯 Key Takeaways

  • Full-stack type safety means one type change propagates from database to UI without manual sync β€” the compiler is the integration test
  • The database schema is the canonical source of truth β€” infer all types from it using Drizzle's $inferSelect and $inferInsert, never write them manually
  • Drizzle types numeric as string and timestamp as Date β€” handle both explicitly in output Zod schemas using coercedDateToISOString
  • Validate at all four boundaries: API input, API output, database results, and external webhooks β€” TypeScript types are compile-time only
  • tRPC requires both .input() AND .output() schemas β€” inference alone does not catch runtime drift in return values
  • Use unique symbol branded types for domain concepts β€” they cannot be forged via type assertion outside the constructor module
  • The shared package contains only the intersection of cross-layer types with zero framework dependencies β€” not the union of all types
  • Errors are data β€” type them with TRPCError codes and Zod field errors, never branch on error.message strings
  • Incremental migration: Zod at boundaries first, then schema-inferred types, then tRPC for new endpoints, then branded types
  • Adding a required column safely requires four deployment steps β€” never add NOT NULL without a default in a single migration

⚠ Common Mistakes to Avoid

    βœ•Manually maintaining TypeScript interfaces that mirror database schema
    Symptom

    After 6 months, 20–30% of interfaces drift from the actual database schema. Runtime errors appear only when specific code paths are exercised in production. Development data never exercises the drifted fields.

    Fix

    Infer types directly from the Drizzle schema using $inferSelect and $inferInsert. Use createSelectSchema and createInsertSchema from drizzle-zod to generate Zod validators from the same source. Delete every manually written interface that mirrors a database table.

    βœ•Not handling the Date serialization gap in Zod output schemas
    Symptom

    validateOutput fails on every query result that contains a timestamp column. The error: 'Expected string, received object' or 'Expected string, received date'. Procedures that were working break after Zod output validation is added.

    Fix

    Drizzle returns timestamp columns as Date objects. Use z.union([z.date().transform(d => d.toISOString()), z.string().datetime()]) for every timestamp field in output schemas. The coercedDateToISOString helper in packages/api/validation.ts handles this in one place.

    βœ•Using primitive types for domain concepts instead of branded types
    Symptom

    A function expects UserId but receives TransactionId. Both are number, so TypeScript does not catch the swap. Production processes the wrong user's data. The bug only appears when the two IDs are different values β€” automated tests with fixed data miss it.

    Fix

    Define branded types using unique symbol: declare const UserIdBrand: unique symbol; type UserId = number & { [UserIdBrand]: typeof UserIdBrand }. Create constructor functions that validate before casting. The constructor is the only place in the codebase where casting to branded types is allowed.

    βœ•Defining API response types manually in the frontend
    Symptom

    Backend changes an API response shape. Frontend compiles without errors because the manually defined types were never updated. Runtime errors appear in production. The type system provided false confidence.

    Fix

    Import types from @company/api or infer them from the tRPC client. Use Pick, Omit, and Partial to derive component-specific types from canonical types. Never write interface ApiResponse in frontend code β€” if you are tempted to, the shared type package is missing the type you need.

    βœ•Adding .output() schema but skipping validateOutput() in the procedure body
    Symptom

    The .output() schema infers the return type correctly but does not catch runtime drift. A database migration adds a nullable column β€” the procedure returns null where the output schema expects a string. The client receives wrong data with no error thrown.

    Fix

    The .output() schema alone provides compile-time inference but not runtime validation. Call validateOutput(schema, data, context) before returning from every procedure. validateOutput runs schema.safeParse and throws on mismatch β€” this is what catches runtime drift.

    βœ•Putting all types in the shared monorepo package
    Symptom

    The shared package imports from React, Express, Prisma, and other framework packages. Every package in the monorepo depends on every other package. Build times increase from seconds to minutes. Circular dependency errors appear in builds.

    Fix

    The shared package contains only the intersection of cross-layer types with zero framework dependencies. Database types belong in the DB package. React component props belong in the frontend. API middleware types belong in the API package. Run madge --circular to detect circular dependencies.

    βœ•Using the old string-literal brand pattern instead of unique symbol brands
    Symptom

    Branded types can be forged anywhere via type assertion: const id = 42 as UserId. This defeats the purpose β€” any code can create a UserId without going through the constructor validation. The brand provides no structural guarantee.

    Fix

    Use unique symbol brands: declare const UserIdBrand: unique symbol; type UserId = number & { [UserIdBrand]: typeof UserIdBrand }. The symbol is not exported, so the brand cannot be forged via type assertion outside the module that declares it.

Interview Questions on This Topic

  • QExplain the concept of full-stack type safety and why it matters in production applications.Mid-levelReveal
    Full-stack type safety means a single type change propagates automatically from the database schema through validation, API, and frontend layers without manual synchronization. It matters in production because the most expensive bugs are data contract violations β€” where one layer sends data in a shape that another layer does not expect. These bugs often pass all tests because each layer is tested in isolation, and only appear in production when specific code paths are exercised. Full-stack type safety turns the TypeScript compiler into an integration test for data contracts: if it compiles, the contract is correct across all layers. The pipeline is: Drizzle schema (source of truth) β†’ inferred types (automatic) β†’ Zod validation (runtime enforcement) β†’ tRPC inference (API contract) β†’ frontend types (inferred from server). A required field added to the database produces a compile error in the frontend form that must include it.
  • QHow would you implement type-safe API boundaries in a TypeScript monorepo with a React frontend and a Node.js backend?SeniorReveal
    Use a four-layer approach. Layer 1: Drizzle ORM for the database. Types are inferred from the schema using $inferSelect and $inferInsert β€” never manually written. Zod schemas are generated from the Drizzle schema using drizzle-zod. Layer 2: Zod validation at all four boundaries β€” API input, API output, database results, and external webhooks. The output schema uses coercedDateToISOString for timestamp fields to handle the Drizzle-to-JSON serialization gap (Drizzle returns Date objects, JSON expects strings). Layer 3: tRPC for the API layer. Every procedure defines both .input() and .output() Zod schemas. Input validates incoming data. Output validates the return value before sending β€” this catches internal drift that TypeScript inference alone cannot detect. Layer 4: The frontend imports types from @company/api or infers them from the tRPC client. No manual API response interfaces. The shared package in the monorepo contains domain types (branded types), API contract shapes, and utility types. It has zero framework dependencies β€” no React, no Drizzle imports.
  • QWhat are branded types and why should you use unique symbols instead of string literals for brands?SeniorReveal
    Branded types are TypeScript types that carry a compile-time-only tag that prevents accidental interchange of structurally identical values. UserId and TransactionId are both number, but TypeScript treats branded versions as incompatible β€” you cannot pass a TransactionId where UserId is expected. String literal brands (type UserId = number & { __brand: 'UserId' }) are the common approach, but they can be forged anywhere in the codebase via type assertion: const id = 42 as UserId. This defeats the purpose β€” any code can bypass the constructor validation. Unique symbol brands are stronger: declare const UserIdBrand: unique symbol; type UserId = number & { [UserIdBrand]: typeof UserIdBrand }. Because the symbol is declared with const and not exported, it cannot be referenced outside the module that declares it. This means you cannot write 42 as UserId outside that module β€” the type assertion would fail to compile because the symbol is not in scope. The constructor function in the DB package is the only place that can produce a valid UserId, ensuring validation always runs.
  • QWhat is the Date serialization gap in Drizzle-based pipelines and how do you handle it?Mid-levelReveal
    Drizzle ORM types timestamp columns as JavaScript Date objects in TypeScript. When a query returns a row with a createdAt column, the value is a Date object in your code. Zod output schemas typically define timestamp fields as z.string().datetime() because the frontend expects ISO strings. When you call validateOutput on a raw Drizzle result, the schema fails β€” it expects a string but receives a Date object. The fix: use a coercing union schema for every timestamp field in output schemas: z.union([z.date().transform(d => d.toISOString()), z.string().datetime()]) This accepts both a Date object (from Drizzle) and an ISO string (if the data was already serialized) and always outputs an ISO string. Define this once as a named helper (coercedDateToISOString) and use it in every output schema that contains a timestamp field. The frontend always receives strings β€” define TransactionResponse.createdAt as string. Parse to Date in the frontend only for display formatting, never for storage.
  • QHow do you handle the synchronization gap between database schema changes and frontend types in a team of 20 engineers?SeniorReveal
    The synchronization gap exists only when types are manually maintained or generated in a separate codegen step. Eliminate it by using inference at every layer. Drizzle infers types directly from the schema β€” no codegen step. drizzle-zod generates Zod schemas from the Drizzle schema at build time β€” validation and types from one source. tRPC infers server procedure types into the client β€” no codegen step. The result: a database schema change produces a TypeScript compile error in the frontend if the contract breaks. No manual sync, no documentation to update, no CI step to regenerate types. For safe migration of required columns: first deploy the migration with the column as nullable (or with a default), then deploy the application code that populates the column, then backfill existing rows, then deploy a second migration making the column NOT NULL, then update the Drizzle schema to .notNull(). This sequence ensures the type chain is never broken at runtime. The only remaining sync gap: if the shared type package is published to a registry rather than consumed as a workspace package, there is a gap between publish and consume. For monorepos, use workspace: protocol throughout β€” never publish internal packages to a registry.
  • QWhen would you choose ts-rest over tRPC for a type-safe API?Mid-levelReveal
    Choose tRPC when both frontend and backend are TypeScript, you want zero codegen with full type inference, and the RPC model (procedure calls, not HTTP methods and paths) fits your team's mental model. Choose ts-rest when: (1) you need explicit HTTP semantics β€” REST verbs and paths matter for your API consumers or documentation, (2) you want to migrate an existing REST API incrementally without changing the URL structure, (3) your team finds the contract-first approach (defining routes as a shared object) easier to review than server-defined procedures, (4) you need to serve non-TypeScript clients from the same contract definition. Both eliminate codegen by sharing a contract between client and server. The difference is model: tRPC uses server-defined procedures, ts-rest uses a shared contract object that both client and server implement independently. For internal TypeScript monorepos with no public API requirements, tRPC is simpler. For APIs that must have a documented HTTP interface, ts-rest is the better fit.

Frequently Asked Questions

Is full-stack type safety worth the setup effort for small projects?

For small projects with fewer than 5 API endpoints, the overhead of setting up tRPC, shared type packages, and full Zod validation may exceed the immediate benefit. Start with two changes: enable strict: true in tsconfig.json, and add Zod validation at API input boundaries. These two changes catch the most common production bugs with minimal setup. Add tRPC and shared types when the project grows to 10+ endpoints or when a second developer joins. The architecture scales β€” starting with the right foundation is cheaper than migrating later.

How does full-stack type safety work with REST APIs instead of tRPC?

Use ts-rest for REST APIs with full type safety. ts-rest defines a shared contract object containing routes, input schemas, and output schemas. Both the server and client implement the same contract β€” types are inferred from it. There is no codegen step.

For teams that cannot adopt ts-rest, use Zod validation on every API handler and export the inferred types from the API package. The frontend imports these types instead of defining its own. This eliminates manual drift but does not provide the automatic propagation that tRPC or ts-rest give you.

OpenAPI codegen is the choice for public APIs that must serve non-TypeScript clients. Run codegen in CI and fail the build if the generated types differ from the committed types β€” this prevents the sync gap from silently growing.

What is the performance overhead of runtime Zod validation in production?

Zod validation adds 1–5ms per validation call depending on schema complexity. For most applications this is negligible compared to database query times (10–100ms) and network latency (50–200ms).

Validation is concentrated at boundaries β€” you validate once per request, not on every internal function call. For high-throughput services processing 10,000+ requests per second where even 1ms matters, use zod-to-json-schema to compile Zod schemas to JSON Schema and validate with ajv at the network boundary. ajv is significantly faster than Zod for pure validation throughput. You can still use Zod for the type inference and use the compiled JSON Schema for runtime validation.

Can I use full-stack type safety with a microservices architecture?

Yes, but the approach changes. In a monorepo, shared types are a workspace package with zero sync gap. In separate repositories, you need a shared type registry.

The options: (1) publish types to an internal npm package β€” introduces a sync gap between publish and consume, mitigated by semantic versioning and build-time version checks. (2) Generate types from an OpenAPI or GraphQL spec β€” codegen in CI for every spec change. (3) Use a schema registry (Protobuf, Avro, JSON Schema) as the source of truth across services.

For microservices, OpenAPI with codegen is usually the right choice β€” it provides a language-agnostic specification, works across teams, and can be enforced in CI. Run contract tests (Pact or similar) to verify that producers and consumers agree on the contract at runtime.

How do you handle database migrations that break the type chain?

A database migration that adds a required column (NOT NULL, no default) breaks the type chain at the schema level β€” all insert operations now require the new field. If the migration is deployed before the code that provides the new field, inserts fail immediately.

The safe four-step pattern: (1) Deploy the migration with the column as nullable or with a DEFAULT value β€” existing inserts do not break. (2) Deploy the application code that populates the new field β€” inserts now provide the value. (3) Backfill existing rows with the correct value. (4) Deploy a second migration that makes the column NOT NULL β€” then update the Drizzle schema to .notNull().

Only after step 4 does the Drizzle type for the column change from optional to required, producing compile errors in any insert code that does not provide the field. The type chain enforces correctness forward β€” the deployment sequence ensures correctness at runtime.

Should I validate environment variables as part of the type safety pipeline?

Yes β€” environment variables are the most commonly overlooked untyped boundary. process.env values are all typed as string | undefined in TypeScript, regardless of what your application expects.

Validate environment variables using Zod at server startup β€” before the server accepts any requests. Define a Zod schema (ServerEnvSchema) that validates required variables, enforces formats (URLs, ports, enums), and provides defaults. Call validateServerEnv() as the first line of server initialization β€” if it throws, the server should not start.

Never access process.env directly in application code. Export a validated env object from the startup module and import it everywhere else. This gives you typed environment variables with the same safety guarantees as the rest of the pipeline.

Do not put this validation in the shared type package β€” process.env is a Node.js runtime API and does not belong in a package with zero framework dependencies.

πŸ”₯
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousBuilding Multi-Agent AI Systems with Next.js and LangGraphNext β†’How I Generate 50+ shadcn Components Automatically with AI
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged