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// ---------------------------------------------------------------exportconst 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),
})
);
exportconst 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// ---------------------------------------------------------------exporttypeUser = typeof users.$inferSelect;
exporttypeNewUser = typeof users.$inferInsert;
exporttypeTransaction = typeof transactions.$inferSelect;
exporttypeNewTransaction = 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// ---------------------------------------------------------------exportconst userSelectSchema = createSelectSchema(users, {
// createdAt and updatedAt are Date objects from Drizzle// No refinement needed here — the select schema reflects the DB reality
});
exportconst 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'),
});
exportconst transactionSelectSchema = createSelectSchema(transactions);
exportconst 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.// ---------------------------------------------------------------declareconstUserIdBrand: unique symbol;
declareconstTransactionIdBrand: unique symbol;
declareconstCurrencyCodeBrand: unique symbol;
declareconstCurrencyPrecisionBrand: unique symbol;
exporttypeUserId = number & { readonly [UserIdBrand]: typeofUserIdBrand };
exporttypeTransactionId = number & {
readonly [TransactionIdBrand]: typeofTransactionIdBrand;
};
exporttypeCurrencyCode = string & {
readonly [CurrencyCodeBrand]: typeofCurrencyCodeBrand;
};
exporttypeCurrencyPrecision = number & {
readonly [CurrencyPrecisionBrand]: typeofCurrencyPrecisionBrand;
};
// Constructor functions validate before casting.// These are the only places in the codebase where casting to branded types is allowed.exportfunctiontoUserId(id: number): UserId {
if (!Number.isInteger(id) || id <= 0) {
thrownewError(`InvalidUserId: ${id} — must be a positive integer`);
}
return id as unknown asUserId;
}
exportfunctiontoTransactionId(id: number): TransactionId {
if (!Number.isInteger(id) || id <= 0) {
thrownewError(`InvalidTransactionId: ${id} — must be a positive integer`);
}
return id as unknown asTransactionId;
}
exportfunctiontoCurrencyCode(code: string): CurrencyCode {
if (!/^[A-Z]{3}$/.test(code)) {
thrownewError(
`InvalidCurrencyCode: "${code}" — must be a 3-letter uppercase ISO4217 code`
);
}
return code as unknown asCurrencyCode;
}
exportfunctiontoCurrencyPrecision(precision: number): CurrencyPrecision {
if (!Number.isInteger(precision) || precision < 0 || precision > 8) {
thrownewError(
`InvalidCurrencyPrecision: ${precision} — must be an integer between 0 and 8`
);
}
return precision as unknown asCurrencyPrecision;
}
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,
typeUserId,
typeTransactionId,
typeCurrencyCode,
} 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.// ---------------------------------------------------------------exportconst 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.// ---------------------------------------------------------------exportconstCreateTransactionInput = 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(),
});
exporttypeCreateTransactionInput = z.infer<typeofCreateTransactionInput>;
// CreateTransactionInput.userId is UserId (branded)// CreateTransactionInput.currency is CurrencyCode (branded)// CreateTransactionInput.amount is string (converted for storage)exportconstGetTransactionInput = z.object({
id: z
.number()
.int('Transaction ID must be an integer')
.positive('Transaction ID must be positive')
.transform(toTransactionId),
});
exporttypeGetTransactionInput = z.infer<typeofGetTransactionInput>;
exportconstListTransactionsInput = 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(),
});
exporttypeListTransactionsInput = z.infer<typeofListTransactionsInput>;
// ---------------------------------------------------------------// 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.// ---------------------------------------------------------------exportconstTransactionResponse = 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,
});
exporttypeTransactionResponse = z.infer<typeofTransactionResponse>;
// TransactionResponse.createdAt is string (ISO 8601)// TransactionResponse.amount is string (decimal)// These are the shapes the frontend will receiveexportconstUserResponse = 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,
});
exporttypeUserResponse = z.infer<typeofUserResponse>;
exportconstPaginatedTransactionsResponse = z.object({
items: z.array(TransactionResponse),
nextCursor: z.number().int().positive().nullable(),
});
exporttypePaginatedTransactionsResponse = z.infer<
typeofPaginatedTransactionsResponse
>;
// ---------------------------------------------------------------// Validation utility functions// Use validateOutput on every response before returning from a procedure.// Use validateDbRow when you need to validate individual query results.// ---------------------------------------------------------------exportfunction 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(
`[TYPEDRIFT] Output validation failed in ${context}:`,
result.error.issues
);
thrownewError(
`Internaltype drift detected in ${context} — check server logs`
);
}
return result.data;
}
exportfunction validateDbRow<T>(
schema: z.ZodSchema<T>,
row: unknown,
context: string
): T {
const result = schema.safeParse(row);
if (!result.success) {
console.error(
`[DBDRIFT] Row does not match expected schema in ${context}:`,
result.error.issues
);
thrownewError(
`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.// ---------------------------------------------------------------exportconstStripeWebhookPayload = 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(),
});
exporttypeStripeWebhookPayload = z.infer<typeofStripeWebhookPayload>;
// ---------------------------------------------------------------// BOUNDARY 4: Environment variable validation// Validate process.env at application startup, not at call time.// This catches missing configuration before the server accepts requests.// ---------------------------------------------------------------exportconstServerEnvSchema = 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'),
});
exporttypeServerEnv = z.infer<typeofServerEnvSchema>;
// Call this at server startup — throws if any env var is missing or invalidexportfunctionvalidateServerEnv(): ServerEnv {
const result = ServerEnvSchema.safeParse(process.env);
if (!result.success) {
console.error('[ENV] Invalid server configuration:', result.error.issues);
thrownewError('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';
exportconst 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]) {
thrownewTRPCError({
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.returnvalidateOutput(
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 transactionconst userExists = await ctx.db.query.users.findFirst({
where: (users, { eq }) => eq(users.id, input.userId),
columns: { id: true },
});
if (!userExists) {
thrownewTRPCError({
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) {
thrownewTRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Transaction insert did not return a row',
});
}
returnvalidateOutput(
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 pageconst 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);
returnvalidateOutput(
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.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 hereimporttype { 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.exportfunctionTransactionList() {
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 codesif (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) returnnull;
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.// ---------------------------------------------------------------interfaceTransactionItemProps {
// Pick only the fields this component uses — not the full type
transaction: Pick<
TransactionResponse,
'id' | 'amount' | 'currency' | 'status' | 'createdAt'
>;
onSelect?: (id: number) => void;
}
functionTransactionItem({ transaction, onSelect }: TransactionItemProps) {
// transaction.createdAt is string (ISO 8601) — defined by TransactionResponse// Parse to Date for display — do not assume the format, always parse explicitlyconst formattedDate = newIntl.DateTimeFormat('en-US', {
dateStyle: 'medium',
}).format(newDate(transaction.createdAt));
const formattedAmount = newIntl.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.// ---------------------------------------------------------------typeCreateTransactionFormState = {
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<(typeofCreateTransactionInput)['safeParse']>['error'] extends infer E
? E extends z.ZodError
? typeof E.prototype.flatten
: never
: never
> | null;
};
functionCreateTransactionForm() {
const [state, setState] = useState<CreateTransactionFormState>({
values: {},
errors: null,
});
const mutation = trpc.transaction.create.useMutation({
onError(error) {
// error.data.code is typed as a TRPCError codeif (error.data?.code === 'BAD_REQUEST') {
console.error('Validation error from server:', error.message);
}
},
});
functionhandleSubmit(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() asany,
}));
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 needexporttype { 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// ---------------------------------------------------------------declareconstUserIdBrand: unique symbol;
declareconstTransactionIdBrand: unique symbol;
declareconstCurrencyCodeBrand: unique symbol;
declareconstCurrencyPrecisionBrand: unique symbol;
exporttypeUserId = number & { readonly [UserIdBrand]: typeofUserIdBrand };
exporttypeTransactionId = number & {
readonly [TransactionIdBrand]: typeofTransactionIdBrand;
};
exporttypeCurrencyCode = string & {
readonly [CurrencyCodeBrand]: typeofCurrencyCodeBrand;
};
exporttypeCurrencyPrecision = number & {
readonly [CurrencyPrecisionBrand]: typeofCurrencyPrecisionBrand;
};
exporttypeTransactionStatus =
| 'pending'
| 'completed'
| 'failed'
| 'refunded';
// ---------------------------------------------------------------// CATEGORY 2: API contract types// Used by both the API package (to produce) and the frontend (to consume)// ---------------------------------------------------------------exportinterfacePaginatedResponse<T> {
items: T[];
nextCursor: number | null;
}
exportinterfaceApiError {
code: string;
message: string;
details?: Record<string, string[]>;
}
exporttypeApiResponse<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 typeexporttypeRequireFields<T, K extends keyof T> = T & Required<Pick<T, K>>;
// Strip branded type tags — useful for serialization and testingexporttypeUnbranded<T> = {
[K in keyof T]: T[K] extendsnumber & { [key: symbol]: unknown }
? number
: T[K] extendsstring & { [key: symbol]: unknown }
? string
: T[K];
};
// Recursive deep partial — useful for update operationsexporttypeDeepPartial<T> = {
[K in keyof T]?: T[K] extendsobject ? DeepPartial<T[K]> : T[K];
};
// Result type for operations that can fail with typed errorsexporttypeResult<T, E = ApiError> =
| { ok: true; value: T }
| { ok: false; error: E };
exportfunction ok<T>(value: T): Result<T> {
return { ok: true, value };
}
exportfunction 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';
importtype { ApiError } from'@company/shared';
// ---------------------------------------------------------------// Typed application error codes// These extend TRPCError codes with domain-specific error types.// ---------------------------------------------------------------exportconstAppErrorCode = {
// Resource errors
NOT_FOUND: 'NOT_FOUND',
ALREADY_EXISTS: 'ALREADY_EXISTS',
// Authorization errorsUNAUTHORIZED: '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',
} asconst;
exporttypeAppErrorCode = (typeofAppErrorCode)[keyof typeofAppErrorCode];
// ---------------------------------------------------------------// Structured error factory// Wraps TRPCError with a consistent shape the frontend can rely on.// ---------------------------------------------------------------exportfunctioncreateAppError(
code: AppErrorCode,
message: string,
details?: Record<string, string[]>
): TRPCError {
// Map domain codes to tRPC HTTP codesconst trpcCode = (() => {
switch (code) {
caseAppErrorCode.NOT_FOUND:
return'NOT_FOUND';
caseAppErrorCode.UNAUTHORIZED:
return'UNAUTHORIZED';
caseAppErrorCode.FORBIDDEN:
caseAppErrorCode.INSUFFICIENT_FUNDS:
return'FORBIDDEN';
caseAppErrorCode.VALIDATION_ERROR:
caseAppErrorCode.INVALID_CURRENCY:
caseAppErrorCode.INVALID_AMOUNT:
caseAppErrorCode.ALREADY_EXISTS:
return'BAD_REQUEST';
default:
return'INTERNAL_SERVER_ERROR';
}
})();
returnnewTRPCError({
code: trpcCode,
message,
cause: details ? newError(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.// ---------------------------------------------------------------exportfunctionzodErrorToFieldErrors(
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 TRPCClientErrorimporttype { TRPCClientError } from'@trpc/client';
importtype { AppRouter } from'../root';
exportfunctionisTRPCError(
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 responseexportfunctionextractFieldErrors(
error: TRPCClientError<AppRouter>
): Record<string, string[]> | null {
if (error.data?.code !== 'BAD_REQUEST') returnnull;
try {
// tRPC serializes Zod issues in error.data.zodErrorconst zodError = error.data?.zodError;
if (!zodError) returnnull;
return (zodError.fieldErrors asRecord<string, string[]>) ?? null;
} catch {
returnnull;
}
}
// ---------------------------------------------------------------// 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)
# EveryJSON.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. Findfetch() 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.
// 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.
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
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
Full — client inferred from Zod-validated API definition
No
No — TypeScript only
REST APIs with Zod-first definitions in TypeScript
Significantly 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.
Q02 of 06SENIOR
How would you implement type-safe API boundaries in a TypeScript monorepo with a React frontend and a Node.js backend?
ANSWER
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.
Q03 of 06SENIOR
What are branded types and why should you use unique symbols instead of string literals for brands?
ANSWER
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.
Q04 of 06SENIOR
What is the Date serialization gap in Drizzle-based pipelines and how do you handle it?
ANSWER
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.
Q05 of 06SENIOR
How do you handle the synchronization gap between database schema changes and frontend types in a team of 20 engineers?
ANSWER
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.
Q06 of 06SENIOR
When would you choose ts-rest over tRPC for a type-safe API?
ANSWER
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.
01
Explain the concept of full-stack type safety and why it matters in production applications.
SENIOR
02
How would you implement type-safe API boundaries in a TypeScript monorepo with a React frontend and a Node.js backend?
SENIOR
03
What are branded types and why should you use unique symbols instead of string literals for brands?
SENIOR
04
What is the Date serialization gap in Drizzle-based pipelines and how do you handle it?
SENIOR
05
How do you handle the synchronization gap between database schema changes and frontend types in a team of 20 engineers?
SENIOR
06
When would you choose ts-rest over tRPC for a type-safe API?
SENIOR
FAQ · 6 QUESTIONS
Frequently Asked Questions
01
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.
Was this helpful?
02
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.
Was this helpful?
03
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.
Was this helpful?
04
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.
Was this helpful?
05
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.
Was this helpful?
06
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.