Full-Stack Type Safety in 2026 β The Ultimate Guide
- Full-stack type safety means one type change propagates from database to UI without manual sync β the compiler is the integration test
- The database schema is the canonical source of truth β infer all types from it using Drizzle's $inferSelect and $inferInsert, never write them manually
- Drizzle types numeric as string and timestamp as Date β handle both explicitly in output Zod schemas using coercedDateToISOString
- Full-stack type safety means a single type change propagates from database schema to UI component without manual sync
- The stack: Drizzle ORM (DB) β drizzle-zod (validation) β tRPC (API) β TypeScript inference (frontend)
- Shared type packages in monorepos are the structural foundation β without them, every layer defines its own types independently
- tRPC eliminates codegen by inferring types from server procedures directly into client calls
- Branded types prevent accidental interchange of structurally identical domain values (UserId vs TransactionId)
- Biggest mistake: type-safe API layer but untyped database queries β the chain breaks at the weakest link
API response shape does not match frontend type definition
curl -s http://localhost:3000/api/transactions | jq '.' > actual-response.jsonnpx tsc --noEmit 2>&1 | grep -i 'type\|interface\|Property'Drizzle types do not match actual query results after schema change
npx drizzle-kit generatenpx tsc --noEmit 2>&1 | grep -i 'schema\|column\|table'Date objects from database failing Zod string validation
grep -rn 'datetime\|coerce' packages/api/node -e "const d = new Date(); console.log(typeof d, d instanceof Date, JSON.stringify(d))"Monorepo type imports resolve to different copies of the same type
find . -path '*/node_modules/@company/shared/package.json' | grep -v '/.git/'cat pnpm-workspace.yamlProduction Incident
Production Debug GuideWhen type mismatches leak through your pipeline
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.Full-stack type safety has moved from a nice-to-have to a production requirement. The cost of runtime type errors β broken API contracts, mismatched database schemas, stale frontend interfaces β compounds silently until a deployment triggers a cascade of failures.
The 2026 tooling landscape has consolidated around a clear pipeline: the database schema defines the source of truth, validation libraries enforce boundaries at runtime, and inference-heavy API layers eliminate codegen entirely. Teams that connect these layers with shared type packages eliminate an entire category of bugs that integration tests were previously needed to catch.
This guide covers the architecture, tools, and production-tested patterns for achieving end-to-end type safety across all four layers: database, validation, API, and frontend. It also covers incremental migration for teams that already have a partially typed stack, error propagation across the chain, and the Date-to-string serialization gap that silently breaks most implementations.
Layer 1: Database Schema as the Source of Truth
Full-stack type safety starts at the database. The schema defines the canonical shape of your data. Every downstream layer β validation, API, frontend β must derive its types from this source, never define them independently.
Drizzle ORM is the dominant choice for type-safe database access in 2026. Unlike Prisma, which generates types in a separate codegen step that introduces a synchronization gap, Drizzle infers types directly from the schema definition. The moment you update the schema file, the types update β no generate command, no restart.
The critical pattern: define your schema once, infer types everywhere. Never manually write TypeScript interfaces that mirror your database schema. The moment you do, you have two sources of truth that will drift.
Drizzle's numeric columns (used for currency) deserve special attention: Drizzle types numeric as string in TypeScript, not number. This is correct β JavaScript cannot represent large decimal values with float precision. Your Zod schemas and application code must handle this.
// packages/db/schema.ts // Database schema β the single source of truth for all types in the system. // All downstream types are inferred from here. Nothing is manually mirrored. import { pgTable, serial, text, integer, timestamp, boolean, numeric, index, } from 'drizzle-orm/pg-core'; import { createInsertSchema, createSelectSchema } from 'drizzle-zod'; import { z } from 'zod'; // --------------------------------------------------------------- // Table definitions // --------------------------------------------------------------- export const users = pgTable( 'users', { id: serial('id').primaryKey(), email: text('email').notNull().unique(), name: text('name').notNull(), // Stored as integer: 2 means 2 decimal places (e.g., USD cents display) currencyPrecision: integer('currency_precision').notNull().default(2), isActive: boolean('is_active').notNull().default(true), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }, (table) => ({ emailIdx: index('users_email_idx').on(table.email), }) ); export const transactions = pgTable( 'transactions', { id: serial('id').primaryKey(), userId: integer('user_id') .notNull() .references(() => users.id), // numeric columns are typed as string by Drizzle β correct for financial values // JavaScript floats cannot represent large decimals precisely amount: numeric('amount', { precision: 12, scale: 2 }).notNull(), currency: text('currency').notNull().default('USD'), status: text('status', { enum: ['pending', 'completed', 'failed', 'refunded'], }).notNull(), metadata: text('metadata'), // JSON stored as text createdAt: timestamp('created_at').notNull().defaultNow(), }, (table) => ({ userIdIdx: index('transactions_user_id_idx').on(table.userId), statusIdx: index('transactions_status_idx').on(table.status), }) ); // --------------------------------------------------------------- // Inferred types β derived from schema, never manually written // --------------------------------------------------------------- export type User = typeof users.$inferSelect; export type NewUser = typeof users.$inferInsert; export type Transaction = typeof transactions.$inferSelect; export type NewTransaction = typeof transactions.$inferInsert; // Transaction['amount'] is string β Drizzle types numeric as string // Transaction['createdAt'] is Date β Drizzle types timestamp as Date // These differ from their JSON-serialized forms and must be handled // explicitly in the API output layer (see Layer 2) // --------------------------------------------------------------- // Zod schemas derived from Drizzle schema via drizzle-zod // drizzle-zod ^0.5 β refinements receive a Zod field schema // and return an augmented schema // --------------------------------------------------------------- export const userSelectSchema = createSelectSchema(users, { // createdAt and updatedAt are Date objects from Drizzle // No refinement needed here β the select schema reflects the DB reality }); export const userInsertSchema = createInsertSchema(users, { email: (schema) => schema.email('Must be a valid email address'), name: (schema) => schema.min(1, 'Name is required').max(255, 'Name too long'), // currencyPrecision defaults to 2 β constrain to valid range currencyPrecision: (schema) => schema .int('Must be an integer') .min(0, 'Precision cannot be negative') .max(8, 'Precision cannot exceed 8'), }); export const transactionSelectSchema = createSelectSchema(transactions); export const transactionInsertSchema = createInsertSchema(transactions, { // amount is string (numeric column) β validate as decimal string amount: () => z .string() .regex(/^\d+(\.\d{1,2})?$/, 'Amount must be a valid decimal with up to 2 places'), currency: (schema) => schema.length(3, 'Currency must be a 3-character ISO 4217 code').toUpperCase(), }); // --------------------------------------------------------------- // Branded types β semantic safety beyond primitive types // // Branded types prevent accidental interchange of structurally // identical values. UserId and TransactionId are both number, // but TypeScript treats them as incompatible types. // // The unique symbol brand is stronger than string brands: // it cannot be forged with `42 as UserId` from outside this module // because the symbol is not exported. // --------------------------------------------------------------- declare const UserIdBrand: unique symbol; declare const TransactionIdBrand: unique symbol; declare const CurrencyCodeBrand: unique symbol; declare const CurrencyPrecisionBrand: unique symbol; export type UserId = number & { readonly [UserIdBrand]: typeof UserIdBrand }; export type TransactionId = number & { readonly [TransactionIdBrand]: typeof TransactionIdBrand; }; export type CurrencyCode = string & { readonly [CurrencyCodeBrand]: typeof CurrencyCodeBrand; }; export type CurrencyPrecision = number & { readonly [CurrencyPrecisionBrand]: typeof CurrencyPrecisionBrand; }; // Constructor functions validate before casting. // These are the only places in the codebase where casting to branded types is allowed. export function toUserId(id: number): UserId { if (!Number.isInteger(id) || id <= 0) { throw new Error(`Invalid UserId: ${id} β must be a positive integer`); } return id as unknown as UserId; } export function toTransactionId(id: number): TransactionId { if (!Number.isInteger(id) || id <= 0) { throw new Error(`Invalid TransactionId: ${id} β must be a positive integer`); } return id as unknown as TransactionId; } export function toCurrencyCode(code: string): CurrencyCode { if (!/^[A-Z]{3}$/.test(code)) { throw new Error( `Invalid CurrencyCode: "${code}" β must be a 3-letter uppercase ISO 4217 code` ); } return code as unknown as CurrencyCode; } export function toCurrencyPrecision(precision: number): CurrencyPrecision { if (!Number.isInteger(precision) || precision < 0 || precision > 8) { throw new Error( `Invalid CurrencyPrecision: ${precision} β must be an integer between 0 and 8` ); } return precision as unknown as CurrencyPrecision; }
- The 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
Layer 2: Validation at Every Boundary
TypeScript types are erased at compile time β they do not exist at runtime. A perfectly typed codebase can still receive malformed data from external sources (API requests, database results, third-party webhooks) without any compile-time warning. Types tell you what you expect. Zod tells you what you actually got.
Zod bridges this gap by providing runtime validation that also produces TypeScript types. Define a Zod schema once and you get both runtime validation and compile-time type inference. The pattern: validate at every boundary where untrusted data enters or exits your system.
The four critical boundaries in a full-stack application: (1) API input β every request body, query parameter, and path parameter. (2) API output β every response before sending, to catch internal type drift. (3) Database results β after queries, to catch migration drift. (4) External sources β every third-party webhook, partner API response, and environment variable.
The Date serialization gap is the most common silent failure in Drizzle-based pipelines. Drizzle returns timestamp columns as JavaScript Date objects. JSON.stringify converts Date objects to ISO strings. Your Zod output schema must account for this conversion explicitly β otherwise validateOutput will fail on every database result that contains a timestamp.
// packages/api/validation.ts // Boundary validation for all four critical boundaries. // // The Date serialization gap: // Drizzle returns timestamp columns as Date objects. // JSON serialization converts Date to ISO string. // Output Zod schemas must handle both forms β the field // arrives as Date from the DB, but leaves as string in the JSON response. // The coercedDateToISOString helper below handles this explicitly. import { z } from 'zod'; import { toUserId, toTransactionId, toCurrencyCode, type UserId, type TransactionId, type CurrencyCode, } from '@company/db'; // --------------------------------------------------------------- // Date serialization helper // Use this for every timestamp field in output schemas. // Accepts a Date object (from Drizzle) or ISO string (already serialized) // and always produces an ISO string for the JSON response. // --------------------------------------------------------------- export const coercedDateToISOString = z .union([ z.date().transform((d) => d.toISOString()), z.string().datetime(), ]) .describe('Timestamp β accepts Date or ISO string, always outputs ISO string'); // --------------------------------------------------------------- // BOUNDARY 1: API input validation // Validates data arriving from the client before it touches the database. // .transform() casts validated primitives to branded types. // --------------------------------------------------------------- export const CreateTransactionInput = z.object({ // Transform validated integer to branded UserId userId: z .number() .int('User ID must be an integer') .positive('User ID must be positive') .transform(toUserId), // amount arrives as a number from the client form // We convert to string for storage (numeric column) amount: z .number() .positive('Amount must be positive') .multipleOf(0.01, 'Amount cannot have more than 2 decimal places') .transform((n) => n.toFixed(2)), // Transform and validate ISO 4217 currency code currency: z .string() .length(3, 'Currency must be a 3-character ISO 4217 code') .transform((s) => s.toUpperCase()) .transform(toCurrencyCode), metadata: z.record(z.string(), z.unknown()).optional(), }); export type CreateTransactionInput = z.infer<typeof CreateTransactionInput>; // CreateTransactionInput.userId is UserId (branded) // CreateTransactionInput.currency is CurrencyCode (branded) // CreateTransactionInput.amount is string (converted for storage) export const GetTransactionInput = z.object({ id: z .number() .int('Transaction ID must be an integer') .positive('Transaction ID must be positive') .transform(toTransactionId), }); export type GetTransactionInput = z.infer<typeof GetTransactionInput>; export const ListTransactionsInput = z.object({ limit: z.number().int().min(1).max(100).default(20), cursor: z.number().int().positive().optional(), status: z .enum(['pending', 'completed', 'failed', 'refunded']) .optional(), }); export type ListTransactionsInput = z.infer<typeof ListTransactionsInput>; // --------------------------------------------------------------- // BOUNDARY 2: API output validation // Validates data leaving the server before it reaches the client. // This catches internal drift: schema changes, transformation bugs, // nullable columns added without updating types. // // Critical: timestamp fields use coercedDateToISOString to handle // the Date-to-string gap between Drizzle and JSON serialization. // --------------------------------------------------------------- export const TransactionResponse = z.object({ id: z.number().int().positive(), userId: z.number().int().positive(), // amount is stored as numeric (string in Drizzle) β validate as decimal string amount: z .string() .regex(/^\d+\.\d{2}$/, 'Amount must be a decimal string with 2 places'), currency: z.string().length(3), status: z.enum(['pending', 'completed', 'failed', 'refunded']), // createdAt: accepts Date from Drizzle OR ISO string if already serialized createdAt: coercedDateToISOString, }); export type TransactionResponse = z.infer<typeof TransactionResponse>; // TransactionResponse.createdAt is string (ISO 8601) // TransactionResponse.amount is string (decimal) // These are the shapes the frontend will receive export const UserResponse = z.object({ id: z.number().int().positive(), email: z.string().email(), name: z.string().min(1), currencyPrecision: z .number() .int() .min(0) .max(8), isActive: z.boolean(), createdAt: coercedDateToISOString, updatedAt: coercedDateToISOString, }); export type UserResponse = z.infer<typeof UserResponse>; export const PaginatedTransactionsResponse = z.object({ items: z.array(TransactionResponse), nextCursor: z.number().int().positive().nullable(), }); export type PaginatedTransactionsResponse = z.infer< typeof PaginatedTransactionsResponse >; // --------------------------------------------------------------- // Validation utility functions // Use validateOutput on every response before returning from a procedure. // Use validateDbRow when you need to validate individual query results. // --------------------------------------------------------------- export function validateOutput<T>( schema: z.ZodSchema<T>, data: unknown, context: string ): T { const result = schema.safeParse(data); if (!result.success) { // Log the full issue list for debugging β do not swallow the error console.error( `[TYPE DRIFT] Output validation failed in ${context}:`, result.error.issues ); throw new Error( `Internal type drift detected in ${context} β check server logs` ); } return result.data; } export function validateDbRow<T>( schema: z.ZodSchema<T>, row: unknown, context: string ): T { const result = schema.safeParse(row); if (!result.success) { console.error( `[DB DRIFT] Row does not match expected schema in ${context}:`, result.error.issues ); throw new Error( `Database result does not match expected schema in ${context}` ); } return result.data; } // --------------------------------------------------------------- // BOUNDARY 3: External webhook validation // Every third-party payload must be validated against an explicit schema. // Never trust external data β validate before processing. // --------------------------------------------------------------- export const StripeWebhookPayload = z.object({ id: z.string().startsWith('evt_', 'Stripe event IDs start with evt_'), type: z.string().min(1), data: z.object({ object: z.object({ id: z.string().startsWith('pi_', 'Payment intent IDs start with pi_'), amount: z .number() .int('Stripe amounts are integers in the smallest currency unit'), currency: z.string().length(3), status: z.enum(['succeeded', 'processing', 'requires_payment_method', 'canceled']), }), }), created: z.number().int(), }); export type StripeWebhookPayload = z.infer<typeof StripeWebhookPayload>; // --------------------------------------------------------------- // BOUNDARY 4: Environment variable validation // Validate process.env at application startup, not at call time. // This catches missing configuration before the server accepts requests. // --------------------------------------------------------------- export const ServerEnvSchema = z.object({ DATABASE_URL: z.string().url('DATABASE_URL must be a valid connection URL'), NODE_ENV: z.enum(['development', 'staging', 'production']), PORT: z .string() .transform(Number) .pipe(z.number().int().min(1024).max(65535)) .default('3000'), }); export type ServerEnv = z.infer<typeof ServerEnvSchema>; // Call this at server startup β throws if any env var is missing or invalid export function validateServerEnv(): ServerEnv { const result = ServerEnvSchema.safeParse(process.env); if (!result.success) { console.error('[ENV] Invalid server configuration:', result.error.issues); throw new Error('Server environment validation failed β see above for details'); } return result.data; }
- 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.toI
SOString()),z.string().datetime()]) to accept both forms - The frontend always receives a string β define TransactionResponse.createdAt as string, not Date
z.string().datetime().Layer 3: Type-Safe API Layer with tRPC
tRPC eliminates the most fragile part of the type safety chain: the API contract. Traditional REST APIs require either manual type definitions that drift or codegen steps that go stale. tRPC infers types directly from server procedures into client calls β no codegen, no sync gap, no separate generate command.
Every tRPC procedure must define both an input schema and an output schema. The input schema validates incoming data. The output schema validates the return value before it leaves the server β this catches internal drift that TypeScript inference alone cannot detect.
Error handling is typed too. tRPC uses TRPCError to send structured errors. The frontend receives typed error codes and can narrow on them. Never throw raw Error objects from tRPC procedures β they reach the client as opaque INTERNAL_SERVER_ERROR responses.
For teams that need REST (public APIs, non-TypeScript clients), use ts-rest. ts-rest defines a shared contract (input and output schemas) that both client and server implement. The same Zod schemas can be reused β the validation logic is identical regardless of the transport layer.
// packages/api/routers/transaction.ts // Type-safe tRPC router with input validation, output validation, // and typed error handling. // // Every procedure defines: // .input() β validates and types incoming data // .output() β validates return value before sending to client // // .output() does two things: // 1. Infers the return type for the client (compile-time) // 2. Validates the actual return value at runtime (catches internal drift) import { z } from 'zod'; import { TRPCError } from '@trpc/server'; import { eq, desc, lt, and } from 'drizzle-orm'; import { router, protectedProcedure } from '../trpc'; import { transactions } from '@company/db'; import { CreateTransactionInput, GetTransactionInput, ListTransactionsInput, TransactionResponse, PaginatedTransactionsResponse, validateOutput, } from '../validation'; export const transactionRouter = router({ // ----------------------------------------------------------------- // getById: fetch a single transaction by ID // ----------------------------------------------------------------- getById: protectedProcedure .input(GetTransactionInput) .output(TransactionResponse) .query(async ({ input, ctx }) => { const rows = await ctx.db .select() .from(transactions) .where(eq(transactions.id, input.id)) .limit(1); if (!rows[0]) { throw new TRPCError({ code: 'NOT_FOUND', message: `Transaction ${input.id} not found`, }); } // validateOutput catches drift between the DB row shape and // what the client expects. coercedDateToISOString in // TransactionResponse handles the Date β string conversion. return validateOutput( TransactionResponse, rows[0], 'transactionRouter.getById' ); }), // ----------------------------------------------------------------- // create: insert a new transaction // ----------------------------------------------------------------- create: protectedProcedure .input(CreateTransactionInput) .output(TransactionResponse) .mutation(async ({ input, ctx }) => { // Verify the user exists before creating the transaction const userExists = await ctx.db.query.users.findFirst({ where: (users, { eq }) => eq(users.id, input.userId), columns: { id: true }, }); if (!userExists) { throw new TRPCError({ code: 'BAD_REQUEST', message: `User ${input.userId} does not exist`, }); } const [created] = await ctx.db .insert(transactions) .values({ userId: input.userId, // amount was transformed to string by the input schema amount: input.amount, // currency was validated and branded by the input schema currency: input.currency, status: 'pending', metadata: input.metadata ? JSON.stringify(input.metadata) : null, }) .returning(); if (!created) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Transaction insert did not return a row', }); } return validateOutput( TransactionResponse, created, 'transactionRouter.create' ); }), // ----------------------------------------------------------------- // list: paginated transaction list with optional status filter // ----------------------------------------------------------------- list: protectedProcedure .input(ListTransactionsInput) .output(PaginatedTransactionsResponse) .query(async ({ input, ctx }) => { const conditions = []; if (input.status) { conditions.push(eq(transactions.status, input.status)); } if (input.cursor) { conditions.push(lt(transactions.id, input.cursor)); } // Fetch one extra row to determine if there is a next page const rows = await ctx.db .select() .from(transactions) .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy(desc(transactions.id)) .limit(input.limit + 1); const hasNextPage = rows.length > input.limit; const pageRows = rows.slice(0, input.limit); return validateOutput( PaginatedTransactionsResponse, { items: pageRows.map((row) => validateOutput( TransactionResponse, row, 'transactionRouter.list.item' ) ), nextCursor: hasNextPage ? pageRows[pageRows.length - 1]?.id ?? null : null, }, 'transactionRouter.list' ); }), }); // ----------------------------------------------------------------- // Typed error handling // // tRPC error codes map to HTTP status codes: // NOT_FOUND β 404 // BAD_REQUEST β 400 // UNAUTHORIZED β 401 // FORBIDDEN β 403 // INTERNAL_SERVER_ERROR β 500 // // The frontend receives typed error codes and can narrow on them: // // const mutation = trpc.transaction.create.useMutation({ // onError(error) { // if (error.data?.code === 'BAD_REQUEST') { // // Handle validation error // } // }, // }); // -----------------------------------------------------------------
- .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
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 here import type { TransactionResponse } from '@company/api'; import { CreateTransactionInput } from '@company/api'; // --------------------------------------------------------------- // WRONG: Manually defining API response types // --------------------------------------------------------------- // interface Transaction { // β Second source of truth // id: number; // amount: string; // currency: string; // status: string; // createdAt: string; // } // // This drifts the moment the API changes. TypeScript will not warn you. // --------------------------------------------------------------- // --------------------------------------------------------------- // CORRECT: Type inferred from tRPC client // --------------------------------------------------------------- // trpc.transaction.list.useQuery() returns data typed as: // { items: TransactionResponse[]; nextCursor: number | null } | undefined // This type comes directly from the server's .output() schema. // A schema change produces a compile error here automatically. export function TransactionList() { const { data, isLoading, error } = trpc.transaction.list.useQuery({ limit: 20, }); if (isLoading) { return <div aria-busy="true">Loading transactions...</div>; } // error.data.code is typed β you can narrow on specific tRPC error codes if (error) { if (error.data?.code === 'UNAUTHORIZED') { return <div>Please sign in to view transactions.</div>; } return <div>Failed to load transactions: {error.message}</div>; } if (!data) return null; return ( <ul> {data.items.map((transaction) => ( <TransactionItem key={transaction.id} transaction={transaction} /> ))} </ul> ); } // --------------------------------------------------------------- // Component props derived from canonical type using utility types. // Never redeclare fields that exist on TransactionResponse. // --------------------------------------------------------------- interface TransactionItemProps { // Pick only the fields this component uses β not the full type transaction: Pick< TransactionResponse, 'id' | 'amount' | 'currency' | 'status' | 'createdAt' >; onSelect?: (id: number) => void; } function TransactionItem({ transaction, onSelect }: TransactionItemProps) { // transaction.createdAt is string (ISO 8601) β defined by TransactionResponse // Parse to Date for display β do not assume the format, always parse explicitly const formattedDate = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', }).format(new Date(transaction.createdAt)); const formattedAmount = new Intl.NumberFormat('en-US', { style: 'currency', currency: transaction.currency, }).format(parseFloat(transaction.amount)); return ( <li onClick={() => onSelect?.(transaction.id)} className="flex justify-between py-2 cursor-pointer" > <span>{formattedAmount}</span> <span>{transaction.status}</span> <time dateTime={transaction.createdAt}>{formattedDate}</time> </li> ); } // --------------------------------------------------------------- // Form: same Zod schema as the server β one definition, both sides. // Client validation catches the same errors the server would reject. // Field error types are inferred from the Zod schema. // --------------------------------------------------------------- type CreateTransactionFormState = { values: Partial<{ userId: number; amount: number; currency: string; metadata?: Record<string, unknown>; }>; // z.ZodError.flatten() produces typed field errors // The type is inferred from the schema β no manual error interfaces errors: ReturnType< ReturnType<(typeof CreateTransactionInput)['safeParse']>['error'] extends infer E ? E extends z.ZodError ? typeof E.prototype.flatten : never : never > | null; }; function CreateTransactionForm() { const [state, setState] = useState<CreateTransactionFormState>({ values: {}, errors: null, }); const mutation = trpc.transaction.create.useMutation({ onError(error) { // error.data.code is typed as a TRPCError code if (error.data?.code === 'BAD_REQUEST') { console.error('Validation error from server:', error.message); } }, }); function handleSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault(); // Client-side validation using the same schema as the server. // .safeParse() on the raw form values β these are pre-transform types. // Note: the schema will run .transform() β userId becomes UserId, etc. const result = CreateTransactionInput.safeParse(state.values); if (!result.success) { setState((prev) => ({ ...prev, errors: result.error.flatten() as any, })); return; } // result.data is fully typed: CreateTransactionInput (post-transform) // userId is UserId (branded), currency is CurrencyCode (branded) mutation.mutate(result.data); } return ( <form onSubmit={handleSubmit}> {state.errors?.fieldErrors.amount && ( <p role="alert" className="text-red-600"> {state.errors.fieldErrors.amount[0]} </p> )} {/* form fields */} <button type="submit" disabled={mutation.isPending}> {mutation.isPending ? 'Creating...' : 'Create Transaction'} </button> </form> ); } // Re-export types consumers of this feature might need export type { TransactionItemProps };
Monorepo Architecture: Shared Types as the Structural Foundation
Shared types are the glue that connects all four layers. In a monorepo, the shared type package is the structural foundation that makes the full type chain possible. Without it, every layer defines its own types independently and drift is structurally inevitable.
The shared package must contain only the intersection of cross-layer types: domain types (branded types, domain enums), API contract types (pagination shapes, error envelopes), and utility types (generic helpers). It must have zero framework dependencies β no React imports, no Express imports, no Drizzle imports, no Prisma imports.
This constraint is not aesthetic β it is structural. If the shared package imports from React, then every package that uses the shared types must also have React as a dependency. This creates circular dependency chains that manifest as build failures and 4-minute CI times.
The package export map is critical for tree-shaking and type resolution. Each sub-path export must be configured explicitly.
// packages/shared/index.ts // The shared type package β the structural foundation of full-stack type safety. // // Rules for this package: // 1. Zero framework dependencies (no React, no Express, no Drizzle, no Prisma) // 2. No layer-specific types (no component props, no middleware types, no ORM queries) // 3. Only types used by two or more layers belong here // 4. This package is the INTERSECTION of cross-layer types β not the union of all types // --------------------------------------------------------------- // CATEGORY 1: Domain types (branded) // These are used by every layer β DB, API, and frontend // The unique symbol brand cannot be forged via type assertion // --------------------------------------------------------------- declare const UserIdBrand: unique symbol; declare const TransactionIdBrand: unique symbol; declare const CurrencyCodeBrand: unique symbol; declare const CurrencyPrecisionBrand: unique symbol; export type UserId = number & { readonly [UserIdBrand]: typeof UserIdBrand }; export type TransactionId = number & { readonly [TransactionIdBrand]: typeof TransactionIdBrand; }; export type CurrencyCode = string & { readonly [CurrencyCodeBrand]: typeof CurrencyCodeBrand; }; export type CurrencyPrecision = number & { readonly [CurrencyPrecisionBrand]: typeof CurrencyPrecisionBrand; }; export type TransactionStatus = | 'pending' | 'completed' | 'failed' | 'refunded'; // --------------------------------------------------------------- // CATEGORY 2: API contract types // Used by both the API package (to produce) and the frontend (to consume) // --------------------------------------------------------------- export interface PaginatedResponse<T> { items: T[]; nextCursor: number | null; } export interface ApiError { code: string; message: string; details?: Record<string, string[]>; } export type ApiResponse<T> = | { data: T; error: null } | { data: null; error: ApiError }; // --------------------------------------------------------------- // CATEGORY 3: Utility types // Generic helpers used across layers // --------------------------------------------------------------- // Make specific fields required on an otherwise-optional type export type RequireFields<T, K extends keyof T> = T & Required<Pick<T, K>>; // Strip branded type tags β useful for serialization and testing export type Unbranded<T> = { [K in keyof T]: T[K] extends number & { [key: symbol]: unknown } ? number : T[K] extends string & { [key: symbol]: unknown } ? string : T[K]; }; // Recursive deep partial β useful for update operations export type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]; }; // Result type for operations that can fail with typed errors export type Result<T, E = ApiError> = | { ok: true; value: T } | { ok: false; error: E }; export function ok<T>(value: T): Result<T> { return { ok: true, value }; } export function err<E = ApiError>(error: E): Result<never, E> { return { ok: false, error }; }
- The 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
Error Handling Across the Type Chain
A complete guide to full-stack type safety cannot stop at the happy path. Errors are data β and they must be typed, validated, and propagated with the same discipline as successful responses.
tRPC provides structured, typed errors via TRPCError. Error codes map to HTTP status codes and are typed on the client. The frontend can narrow on error.data.code to handle specific error conditions without string-matching on error messages.
Zod validation failures produce a ZodError with typed field-level issues. When validation fails at the API input boundary, tRPC automatically converts it to a BAD_REQUEST error with the Zod issue list. The frontend receives this as a typed error and can map field-level issues to form field errors without additional parsing.
The error propagation chain: Zod validation failure β TRPCError (BAD_REQUEST) β typed error on client β field-level error display in form. Each step is typed. No string parsing, no manual error shaping.
// packages/api/error-handling.ts // Typed error patterns for the full-stack type chain. // Error codes, structured error responses, and client-side error handling. import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import type { ApiError } from '@company/shared'; // --------------------------------------------------------------- // Typed application error codes // These extend TRPCError codes with domain-specific error types. // --------------------------------------------------------------- export const AppErrorCode = { // Resource errors NOT_FOUND: 'NOT_FOUND', ALREADY_EXISTS: 'ALREADY_EXISTS', // Authorization errors UNAUTHORIZED: 'UNAUTHORIZED', FORBIDDEN: 'FORBIDDEN', INSUFFICIENT_FUNDS: 'INSUFFICIENT_FUNDS', // Validation errors VALIDATION_ERROR: 'VALIDATION_ERROR', INVALID_CURRENCY: 'INVALID_CURRENCY', INVALID_AMOUNT: 'INVALID_AMOUNT', // System errors INTERNAL_ERROR: 'INTERNAL_ERROR', DATABASE_ERROR: 'DATABASE_ERROR', EXTERNAL_SERVICE_ERROR: 'EXTERNAL_SERVICE_ERROR', } as const; export type AppErrorCode = (typeof AppErrorCode)[keyof typeof AppErrorCode]; // --------------------------------------------------------------- // Structured error factory // Wraps TRPCError with a consistent shape the frontend can rely on. // --------------------------------------------------------------- export function createAppError( code: AppErrorCode, message: string, details?: Record<string, string[]> ): TRPCError { // Map domain codes to tRPC HTTP codes const trpcCode = (() => { switch (code) { case AppErrorCode.NOT_FOUND: return 'NOT_FOUND'; case AppErrorCode.UNAUTHORIZED: return 'UNAUTHORIZED'; case AppErrorCode.FORBIDDEN: case AppErrorCode.INSUFFICIENT_FUNDS: return 'FORBIDDEN'; case AppErrorCode.VALIDATION_ERROR: case AppErrorCode.INVALID_CURRENCY: case AppErrorCode.INVALID_AMOUNT: case AppErrorCode.ALREADY_EXISTS: return 'BAD_REQUEST'; default: return 'INTERNAL_SERVER_ERROR'; } })(); return new TRPCError({ code: trpcCode, message, cause: details ? new Error(JSON.stringify(details)) : undefined, }); } // --------------------------------------------------------------- // Zod error β field error map // Use this to convert Zod validation failures to form field errors // on the client side. The output type is explicitly mapped so the // frontend can render field-level error messages without string parsing. // --------------------------------------------------------------- export function zodErrorToFieldErrors( error: z.ZodError ): Record<string, string[]> { const fieldErrors: Record<string, string[]> = {}; for (const issue of error.issues) { const path = issue.path.join('.'); const key = path || '_root'; if (!fieldErrors[key]) { fieldErrors[key] = []; } fieldErrors[key].push(issue.message); } return fieldErrors; } // --------------------------------------------------------------- // Client-side error utilities // These run in the frontend β imported from @company/api // --------------------------------------------------------------- // Type guard for TRPCClientError import type { TRPCClientError } from '@trpc/client'; import type { AppRouter } from '../root'; export function isTRPCError( error: unknown ): error is TRPCClientError<AppRouter> { return ( error !== null && typeof error === 'object' && 'data' in error && 'message' in error ); } // Extract typed field errors from a tRPC validation error response export function extractFieldErrors( error: TRPCClientError<AppRouter> ): Record<string, string[]> | null { if (error.data?.code !== 'BAD_REQUEST') return null; try { // tRPC serializes Zod issues in error.data.zodError const zodError = error.data?.zodError; if (!zodError) return null; return (zodError.fieldErrors as Record<string, string[]>) ?? null; } catch { return null; } } // --------------------------------------------------------------- // Frontend usage example: // // const mutation = trpc.transaction.create.useMutation({ // onError(error) { // if (isTRPCError(error)) { // const fieldErrors = extractFieldErrors(error); // if (fieldErrors) { // setFormErrors(fieldErrors); // Record<string, string[]> // return; // } // if (error.data?.code === 'INSUFFICIENT_FUNDS') { // showToast('Insufficient funds for this transaction'); // return; // } // } // }, // }); // ---------------------------------------------------------------
- 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
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).
#!/usr/bin/env bash # audit-type-safety.sh # Run before starting migration to understand the current state of type safety. # Produces a prioritized list of the riskiest untyped boundaries. set -euo pipefail echo "==================================================================" echo " Full-Stack Type Safety Audit" echo "==================================================================" echo "" # ----------------------------------------------------------------- # 1. Find manually defined API response interfaces (migration targets) # These interfaces may have drifted from the actual API responses. # ----------------------------------------------------------------- echo "--- Manually defined API interfaces (potential drift sources) ---" grep -rn --include='*.ts' --include='*.tsx' \ -E 'interface [A-Z][a-zA-Z]*(Response|Request|Payload|ApiResult)' \ apps/ src/ \ | sort \ | head -30 echo "" # ----------------------------------------------------------------- # 2. Find unvalidated JSON.parse calls (runtime type risk) # Every JSON.parse without a Zod schema is a potential runtime error. # ----------------------------------------------------------------- echo "--- Unvalidated JSON.parse calls (add Zod validation here) ---" grep -rn --include='*.ts' --include='*.tsx' \ 'JSON\.parse' \ apps/ src/ packages/ \ | grep -v 'safeParse\|schema\.parse\|\.parse(' \ | grep -v node_modules \ | grep -v '\.test\.' \ | head -20 echo "" # ----------------------------------------------------------------- # 3. Find 'as unknown as' or forced type assertions (type safety bypasses) # Forced assertions hide real type mismatches. # ----------------------------------------------------------------- echo "--- Forced type assertions (potential type safety bypasses) ---" grep -rn --include='*.ts' --include='*.tsx' \ -E 'as unknown as|as any' \ apps/ src/ packages/ \ | grep -v node_modules \ | grep -v '\.test\.' \ | grep -v '// safe:' \ | sort \ | head -20 echo "" # ----------------------------------------------------------------- # 4. Find fetch() calls without response type validation # Every raw fetch() is a potential untyped API boundary. # ----------------------------------------------------------------- echo "--- Raw fetch() calls without Zod parsing (migration priority) ---" grep -rn --include='*.ts' --include='*.tsx' \ 'await fetch(' \ apps/ src/ \ | grep -v 'safeParse\|schema\.parse\|z\.object' \ | grep -v node_modules \ | head -20 echo "" # ----------------------------------------------------------------- # 5. TypeScript strict mode status per package # Packages without strict: true have hidden type errors. # ----------------------------------------------------------------- echo "--- TypeScript strict mode status per tsconfig ---" find . -name 'tsconfig.json' \ -not -path '*/node_modules/*' \ -not -path '*/.git/*' \ | while read -r f; do strict=$(python3 -c " import json, sys try: d = json.load(open('$f')) print(d.get('compilerOptions', {}).get('strict', 'NOT SET')) except: print('PARSE ERROR') " 2>/dev/null) echo " $f β strict: $strict" done echo "" echo "==================================================================" echo " Recommended migration order:" echo " 1. Enable strict: true in all tsconfig.json files" echo " 2. Add Zod validation to all fetch() calls above" echo " 3. Replace manual API interfaces with Zod-inferred types" echo " 4. Migrate high-traffic endpoints to tRPC" echo " 5. Add branded types for IDs and domain values" echo "=================================================================="
- 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
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.
- Schema change β inferred types update β Zod schemas update β tRPC types update β frontend compile error
- No manual synchronization steps β the chain is fully automated via type inference and schema derivation
- The compiler catches contract violations before they reach production
- Adding a required field to the database produces a compile error in the frontend form β no runtime test needed
- This replaces the class of integration tests that check API response shapes β the compiler does it for free
| Approach | Type Inference | Codegen Required | Multi-Language Support | Best For | Primary Weakness |
|---|---|---|---|---|---|
| tRPC v11 | Full β server procedures inferred directly into client | No β inference at build time | No β TypeScript only | Monorepo TypeScript apps with shared frontend and backend | Cannot serve non-TypeScript clients β not suitable for public APIs |
| ts-rest | Full β shared contract inferred by both client and server | No β contract file is the source | No β TypeScript only | REST APIs that need TypeScript type safety without tRPC's RPC model | More boilerplate than tRPC β contract file must be maintained manually |
| OpenAPI + codegen | Partial β generated from spec, types can go stale | Yes β must regenerate after every spec change | Yes β any language | Public APIs, polyglot stacks, external partner integrations | Sync gap between spec and code β stale types if codegen is not run in CI |
| GraphQL + codegen | Full β schema to resolvers to client hooks | Yes β must regenerate after schema changes | Yes β any language | Complex data graphs with flexible client queries, polyglot stacks | Schema-first overhead, N+1 risk, codegen toolchain complexity |
| Zodios | 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
- Full-stack type safety means one type change propagates from database to UI without manual sync β the compiler is the integration test
- The database schema is the canonical source of truth β infer all types from it using Drizzle's $inferSelect and $inferInsert, never write them manually
- Drizzle types numeric as string and timestamp as Date β handle both explicitly in output Zod schemas using coercedDateToISOString
- Validate at all four boundaries: API input, API output, database results, and external webhooks β TypeScript types are compile-time only
- tRPC requires both .input() AND .output() schemas β inference alone does not catch runtime drift in return values
- Use unique symbol branded types for domain concepts β they cannot be forged via type assertion outside the constructor module
- The shared package contains only the intersection of cross-layer types with zero framework dependencies β not the union of all types
- Errors are data β type them with TRPCError codes and Zod field errors, never branch on error.message strings
- Incremental migration: Zod at boundaries first, then schema-inferred types, then tRPC for new endpoints, then branded types
- Adding a required column safely requires four deployment steps β never add NOT NULL without a default in a single migration
β Common Mistakes to Avoid
Interview Questions on This Topic
- QExplain the concept of full-stack type safety and why it matters in production applications.Mid-levelReveal
- QHow would you implement type-safe API boundaries in a TypeScript monorepo with a React frontend and a Node.js backend?SeniorReveal
- QWhat are branded types and why should you use unique symbols instead of string literals for brands?SeniorReveal
- QWhat is the Date serialization gap in Drizzle-based pipelines and how do you handle it?Mid-levelReveal
- QHow do you handle the synchronization gap between database schema changes and frontend types in a team of 20 engineers?SeniorReveal
- QWhen would you choose ts-rest over tRPC for a type-safe API?Mid-levelReveal
Frequently Asked Questions
Is full-stack type safety worth the setup effort for small projects?
For small projects with fewer than 5 API endpoints, the overhead of setting up tRPC, shared type packages, and full Zod validation may exceed the immediate benefit. Start with two changes: enable strict: true in tsconfig.json, and add Zod validation at API input boundaries. These two changes catch the most common production bugs with minimal setup. Add tRPC and shared types when the project grows to 10+ endpoints or when a second developer joins. The architecture scales β starting with the right foundation is cheaper than migrating later.
How does full-stack type safety work with REST APIs instead of tRPC?
Use ts-rest for REST APIs with full type safety. ts-rest defines a shared contract object containing routes, input schemas, and output schemas. Both the server and client implement the same contract β types are inferred from it. There is no codegen step.
For teams that cannot adopt ts-rest, use Zod validation on every API handler and export the inferred types from the API package. The frontend imports these types instead of defining its own. This eliminates manual drift but does not provide the automatic propagation that tRPC or ts-rest give you.
OpenAPI codegen is the choice for public APIs that must serve non-TypeScript clients. Run codegen in CI and fail the build if the generated types differ from the committed types β this prevents the sync gap from silently growing.
What is the performance overhead of runtime Zod validation in production?
Zod validation adds 1β5ms per validation call depending on schema complexity. For most applications this is negligible compared to database query times (10β100ms) and network latency (50β200ms).
Validation is concentrated at boundaries β you validate once per request, not on every internal function call. For high-throughput services processing 10,000+ requests per second where even 1ms matters, use zod-to-json-schema to compile Zod schemas to JSON Schema and validate with ajv at the network boundary. ajv is significantly faster than Zod for pure validation throughput. You can still use Zod for the type inference and use the compiled JSON Schema for runtime validation.
Can I use full-stack type safety with a microservices architecture?
Yes, but the approach changes. In a monorepo, shared types are a workspace package with zero sync gap. In separate repositories, you need a shared type registry.
The options: (1) publish types to an internal npm package β introduces a sync gap between publish and consume, mitigated by semantic versioning and build-time version checks. (2) Generate types from an OpenAPI or GraphQL spec β codegen in CI for every spec change. (3) Use a schema registry (Protobuf, Avro, JSON Schema) as the source of truth across services.
For microservices, OpenAPI with codegen is usually the right choice β it provides a language-agnostic specification, works across teams, and can be enforced in CI. Run contract tests (Pact or similar) to verify that producers and consumers agree on the contract at runtime.
How do you handle database migrations that break the type chain?
A database migration that adds a required column (NOT NULL, no default) breaks the type chain at the schema level β all insert operations now require the new field. If the migration is deployed before the code that provides the new field, inserts fail immediately.
The safe four-step pattern: (1) Deploy the migration with the column as nullable or with a DEFAULT value β existing inserts do not break. (2) Deploy the application code that populates the new field β inserts now provide the value. (3) Backfill existing rows with the correct value. (4) Deploy a second migration that makes the column NOT NULL β then update the Drizzle schema to .notNull().
Only after step 4 does the Drizzle type for the column change from optional to required, producing compile errors in any insert code that does not provide the field. The type chain enforces correctness forward β the deployment sequence ensures correctness at runtime.
Should I validate environment variables as part of the type safety pipeline?
Yes β environment variables are the most commonly overlooked untyped boundary. process.env values are all typed as string | undefined in TypeScript, regardless of what your application expects.
Validate environment variables using Zod at server startup β before the server accepts any requests. Define a Zod schema (ServerEnvSchema) that validates required variables, enforces formats (URLs, ports, enums), and provides defaults. Call validateServerEnv() as the first line of server initialization β if it throws, the server should not start.
Never access process.env directly in application code. Export a validated env object from the startup module and import it everywhere else. This gives you typed environment variables with the same safety guarantees as the rest of the pipeline.
Do not put this validation in the shared type package β process.env is a Node.js runtime API and does not belong in a package with zero framework dependencies.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.