Mid-level 8 min · April 14, 2026

Full-Stack Type Safety — The $2.3M Type Mismatch

A $2.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
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
What is Full-Stack Type Safety?

Full-stack type safety means that a single change to a data shape—say, adding a phoneNumber field to a user record in PostgreSQL—propagates automatically through every layer of your application: the database schema, the API layer, the validation logic, and the frontend components. Without it, you rely on manual coordination, runtime checks, and hope.

The $2.3M figure isn't hyperbole; it's a conservative estimate of what a single type mismatch cost a fintech startup in 2022 when a backend schema change silently broke a payment flow for 48 hours. This approach exists to eliminate entire categories of bugs—the ones that pass tests, compile fine, and explode in production because a string became a number somewhere in the middle.

In practice, full-stack type safety is a stack of four layers, each enforcing the contract at a different boundary. Layer 1 uses the database schema as the source of truth—tools like Prisma or Drizzle generate TypeScript types directly from your SQL migrations.

Layer 2 adds validation at every entry point (Zod, Valibot, or ArkType) to catch malformed data before it touches your business logic. Layer 3 is the API layer itself, where tRPC or GraphQL Codegen ensures that the types your frontend sends and receives match exactly what your backend expects.

Layer 4 consumes those types on the frontend, so your React or Vue components know the exact shape of every API response at compile time.

This isn't the right tool for every project. If you're building a simple marketing site or a prototype that will be rewritten in six months, the overhead of setting up a monorepo with shared types, code generation, and strict validation isn't worth it.

But for any application where data flows through multiple services, teams, or deployment cycles—think SaaS platforms, fintech, healthcare, or any system where a type mismatch means a pager goes off at 3 AM—this architecture is the difference between shipping with confidence and shipping with fingers crossed. The monorepo (using Turborepo, Nx, or pnpm workspaces) is the structural foundation that makes it all work: shared types live in a single package, and every consumer gets the same truth, enforced by the compiler.

Plain-English First

Imagine a factory assembly line where every station uses a different measurement system — station one uses inches, station two uses centimeters, station three eyeballs it. That is a typical full-stack app without type safety: the database defines one shape, the API transforms it, the frontend assumes another shape, and bugs hide in the conversion gaps. Full-stack type safety is standardizing the entire line to one measurement system. When a specification changes at station one, every downstream station automatically knows about it — and refuses to compile if it cannot handle the change.

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.

Full-Stack Type Safety — The $2.3M Type Mismatch

Full-stack type safety means a single type definition flows from your database schema through your API layer to your frontend UI, with the compiler enforcing consistency at every boundary. The core mechanic is a shared type contract — typically defined in a language-agnostic schema (e.g., Protobuf, GraphQL, or OpenAPI) — that both server and client import and validate against. When the backend changes a field from int to string, the frontend build fails before it ever reaches production.

In practice, this eliminates an entire class of runtime errors: mismatched payloads, missing fields, and silent undefined crashes. The key properties are compile-time enforcement (not runtime checks), bidirectional validation (both sides must conform), and automatic code generation from the schema. For a Java backend with a TypeScript frontend, tools like gRPC-Web or tRPC generate typed clients that mirror your Java DTOs exactly — no manual mapping, no guesswork.

Use full-stack type safety on any system where a data mismatch can cause a production incident — which is every system with more than one service. The cost of a single type mismatch in a high-traffic checkout flow can exceed $2.3M in lost revenue and incident response time. It matters most at API boundaries: REST endpoints, GraphQL resolvers, and event bus messages. Without it, you're debugging a 'cannot read property X of undefined' in production, guessing which service sent the wrong shape.

Not Just TypeScript
Full-stack type safety isn't a frontend luxury — Java backends benefit equally. A mismatched LocalDate vs String in a gRPC contract causes silent data corruption, not a compile error.
Production Insight
A fintech team shipped a new field accountType as int in the Java DTO but string in the TypeScript client — the frontend rendered '2' instead of 'Checking', causing a $1.2M misrouting of ACH transfers.
Symptom: no compile error, no runtime exception — just wrong data displayed and acted upon for 3 hours before detection.
Rule: always generate client code from the server schema; never hand-write API types. Use a single source of truth (Protobuf, OpenAPI) and regenerate on every build.
Key Takeaway
One type definition, two languages — the compiler is your cheapest integration test.
Runtime type checks are a safety net, not a strategy — enforce at compile time or pay in incident response.
Schema-first design (Protobuf/OpenAPI) is non-negotiable for any system with >1 service boundary.

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.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
// 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;
}
The Single Source of Truth Principle
  • 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.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
// 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;
}
The Date Serialization Gap
  • 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.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
// 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
//       }
//     },
//   });
// -----------------------------------------------------------------
Why Both Input AND Output Schemas Are Mandatory
  • .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.tsxTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
// 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
  • If a frontend developer writes interface ApiResponse, they have created a second source of truth that will silently drift
  • Import types from @company/api or infer them from the tRPC client — never define manually
  • Use Pick<TransactionResponse, 'id' | 'amount'> to derive component-specific types from the canonical type
  • Form validation must use the same Zod schema as the server — one definition, both sides, zero drift
  • tRPC error codes are typed — narrow on error.data.code instead of error.message strings
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.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
// 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.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
// 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;
//       }
//     }
//   },
// });
// ---------------------------------------------------------------
Errors Are Data — Type Them Like Data
  • 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.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
#!/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.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
// 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.
The Compiler as Integration 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.
● Production incidentPOST-MORTEMseverity: high

The Type Mismatch That Cost $2.3 Million in Failed Transactions

Symptom
Customer 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.
Assumption
The 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 cause
The 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.
Fix
Created 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 concepts
  • The API boundary is where server and client contracts meet — it is the most critical validation point
  • Shared type packages eliminate the gap where each layer reinterprets the same data differently
  • A 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 pipeline6 entries
Symptom · 01
Frontend receives data that does not match its TypeScript types — runtime errors on property access
Fix
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.
Symptom · 02
Database query returns Date objects but Zod schema expects strings — validateOutput throws on every query
Fix
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.
Symptom · 03
Drizzle or Prisma types do not match actual query results after a migration
Fix
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.
Symptom · 04
tRPC procedure compiles but returns incorrect data shape to the client
Fix
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.
Symptom · 05
Shared type package causes circular dependency errors in monorepo builds
Fix
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.
Symptom · 06
CI passes but production throws TypeError — works in development
Fix
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.
★ Type Safety Quick Debug Cheat SheetWhen type errors leak through your full-stack pipeline, run through this checklist.
API response shape does not match frontend type definition
Immediate action
Capture 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 now
Add 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 action
Regenerate Drizzle types and restart the TypeScript language server
Commands
npx drizzle-kit generate
npx tsc --noEmit 2>&1 | grep -i 'schema\|column\|table'
Fix now
After 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 action
Check 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 now
Add .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 action
Check for duplicate installations of the shared package
Commands
find . -path '*/node_modules/@company/shared/package.json' | grep -v '/.git/'
cat pnpm-workspace.yaml
Fix now
Ensure the shared package is listed in workspace dependencies using the workspace: protocol (workspace:*), not installed from a registry
Type-Safe API Approaches Compared
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

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

Common mistakes to avoid

7 patterns
×

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 PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the concept of full-stack type safety and why it matters in prod...
Q02SENIOR
How would you implement type-safe API boundaries in a TypeScript monorep...
Q03SENIOR
What are branded types and why should you use unique symbols instead of ...
Q04SENIOR
What is the Date serialization gap in Drizzle-based pipelines and how do...
Q05SENIOR
How do you handle the synchronization gap between database schema change...
Q06SENIOR
When would you choose ts-rest over tRPC for a type-safe API?
Q01 of 06SENIOR

Explain the concept of full-stack type safety and why it matters in production applications.

ANSWER
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.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Is full-stack type safety worth the setup effort for small projects?
02
How does full-stack type safety work with REST APIs instead of tRPC?
03
What is the performance overhead of runtime Zod validation in production?
04
Can I use full-stack type safety with a microservices architecture?
05
How do you handle database migrations that break the type chain?
06
Should I validate environment variables as part of the type safety pipeline?
🔥

That's React.js. Mark it forged?

8 min read · try the examples if you haven't

Previous
Building Multi-Agent AI Systems with Next.js and LangGraph
46 / 47 · React.js
Next
How I Generate 50+ shadcn Components Automatically with AI