Full-Stack Engineering
TypeScript Patterns I Use in Every Production App
Last updated: April 14, 2026
TL;DR
These are the typescript patterns I reach for in every production app I build. Not theoretical blog post patterns — actual code from apps handling real traffic. Strict mode with zero any types. Zod schemas that validate at runtime what TypeScript checks at compile time. Discriminated unions that make impossible states unrepresentable. Branded types that prevent you from passing a userId where an orderId belongs. Generic API clients that type your entire request-response cycle. Type-safe environment variables that fail at build time, not at 3am in production. I also cover the patterns I stopped using because they created more problems than they solved. If you're writing TypeScript for anything that faces users, these patterns will save you from the bugs that type inference alone can't catch.
Strict Mode Is Non-Negotiable
Every tsconfig.json in every project I ship starts the same way. Not "strict": true — that's the minimum. I go further.
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"noPropertyAccessFromIndexSignature": true
}
}The flag most developers miss is noUncheckedIndexedAccess. Without it, TypeScript lies to you:
const users = ["Alice", "Bob", "Charlie"];
// Without noUncheckedIndexedAccess:
const first = users[0]; // type: string (WRONG — could be undefined)
// With noUncheckedIndexedAccess:
const first = users[0]; // type: string | undefined (CORRECT)This single flag has caught more bugs in my code than any linter rule. Array access, object index signatures, Map lookups — all of these can return undefined, and without this flag TypeScript pretends they can't.
exactOptionalPropertyTypes is another one most teams skip. It prevents you from explicitly assigning undefined to optional properties, which sounds pedantic until you're debugging why your Prisma update query is nullifying fields you never intended to touch:
interface UpdateUserInput {
name?: string;
email?: string;
}
// Without exactOptionalPropertyTypes:
const input: UpdateUserInput = { name: "Uvin", email: undefined };
// This compiles fine but sends email: undefined to your ORM
// which might interpret it as "set email to null"
// With exactOptionalPropertyTypes:
const input: UpdateUserInput = { name: "Uvin", email: undefined };
// Error: Type 'undefined' is not assignable to type 'string'My rule: if a tsconfig flag exists and makes the type system stricter, I turn it on. The five minutes you spend fixing the resulting errors saves five hours debugging a production issue that strict mode would have caught.
Zod for Runtime Validation
TypeScript's type system evaporates at runtime. Your perfectly typed API handler receives JSON.parse(body), and that result is any — or unknown if you're disciplined. This is where Zod earns its place in every project I build.
I don't just validate API inputs. I validate everything that crosses a trust boundary: API responses from third parties, environment variables, URL search params, form submissions, webhook payloads, and database query results when the schema might have drifted.
Here's the pattern I use for API route handlers in Next.js:
import { z } from "zod";
const CreateOrderSchema = z.object({
items: z.array(
z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive().max(100),
variant: z.string().optional(),
})
).min(1).max(50),
shippingAddress: z.object({
line1: z.string().min(1).max(200),
line2: z.string().max(200).optional(),
city: z.string().min(1).max(100),
postalCode: z.string().regex(/^[A-Z0-9\s-]{3,10}$/i),
country: z.string().length(2),
}),
couponCode: z.string().max(20).optional(),
});
type CreateOrderInput = z.infer<typeof CreateOrderSchema>;
export async function POST(request: Request) {
const body = await request.json();
const result = CreateOrderSchema.safeParse(body);
if (!result.success) {
return Response.json(
{
code: "VALIDATION_ERROR",
message: "Invalid order data",
details: result.error.flatten().fieldErrors,
},
{ status: 400 }
);
}
// result.data is fully typed as CreateOrderInput
const order = await createOrder(result.data);
return Response.json(order, { status: 201 });
}The key insight: z.infer<typeof Schema> means you write the validation once and get the TypeScript type for free. No maintaining parallel type definitions. The schema IS the type.
For third-party API responses, I use Zod's .passthrough() to validate the fields I care about while ignoring extra fields the API might add:
const StripeWebhookEventSchema = z.object({
id: z.string().startsWith("evt_"),
type: z.string(),
data: z.object({
object: z.record(z.unknown()),
}),
created: z.number(),
}).passthrough();
function handleWebhook(rawBody: unknown) {
const event = StripeWebhookEventSchema.parse(rawBody);
// event.type is string, event.id starts with "evt_"
// If the shape doesn't match, we get a clear error
// instead of a "Cannot read property 'type' of undefined" at 3am
}Zod transforms are underrated. I use them to coerce and normalize data at the validation boundary:
const SearchParamsSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
sort: z.enum(["newest", "oldest", "popular"]).default("newest"),
q: z.string().trim().toLowerCase().max(200).optional(),
});URL search params arrive as strings. Zod coerces "3" to 3, trims whitespace, lowercases search queries — all at the validation step. The code downstream never handles raw strings or conversion edge cases.
Discriminated Unions for State Machines
This is the pattern that changed how I model state. If you have data that can be in multiple states, and each state has different associated data, discriminated unions make illegal states unrepresentable.
Here's a real example from an order tracking system:
type OrderStatus =
| { status: "pending"; createdAt: Date }
| { status: "confirmed"; confirmedAt: Date; estimatedDelivery: Date }
| { status: "shipped"; shippedAt: Date; trackingNumber: string; carrier: string }
| { status: "delivered"; deliveredAt: Date; signedBy: string }
| { status: "cancelled"; cancelledAt: Date; reason: string; refundId: string }
| { status: "failed"; failedAt: Date; error: string; retryable: boolean };Notice: a "pending" order doesn't have a trackingNumber. A "cancelled" order always has a reason and refundId. You can't accidentally render a tracking link for a pending order — the type system won't let you access trackingNumber until you've narrowed the union.
function renderOrderBanner(order: OrderStatus): string {
switch (order.status) {
case "pending":
return "Your order is being processed.";
case "confirmed":
return `Estimated delivery: ${order.estimatedDelivery.toLocaleDateString()}`;
case "shipped":
return `Shipped via ${order.carrier}. Tracking: ${order.trackingNumber}`;
case "delivered":
return `Delivered. Signed by ${order.signedBy}`;
case "cancelled":
return `Cancelled: ${order.reason}`;
case "failed":
return order.retryable
? `Order failed: ${order.error}. Retrying...`
: `Order failed: ${order.error}. Please contact support.`;
}
}TypeScript narrows the type inside each case branch. You get autocomplete for the fields that exist in that state. If you add a new status, TypeScript flags every switch statement that doesn't handle it — but only if you enable exhaustive checking:
function assertNever(value: never): never {
throw new Error(`Unhandled discriminant: ${JSON.stringify(value)}`);
}
function getOrderIcon(order: OrderStatus): string {
switch (order.status) {
case "pending": return "clock";
case "confirmed": return "check";
case "shipped": return "truck";
case "delivered": return "package-check";
case "cancelled": return "x-circle";
case "failed": return "alert-triangle";
default: return assertNever(order);
}
}If someone adds | { status: "refunded"; ... } to the union, assertNever(order) immediately errors at compile time because order would be { status: "refunded"; ... } in the default branch, not never.
I use this pattern for API responses too:
type ApiResponse<T> =
| { success: true; data: T }
| { success: false; error: { code: string; message: string } };
async function fetchUser(id: string): Promise<ApiResponse<User>> {
try {
const user = await db.user.findUnique({ where: { id } });
if (!user) {
return { success: false, error: { code: "NOT_FOUND", message: "User not found" } };
}
return { success: true, data: user };
} catch {
return { success: false, error: { code: "INTERNAL", message: "Database error" } };
}
}
// Usage — you MUST check success before accessing data
const result = await fetchUser("abc");
if (result.success) {
console.log(result.data.name); // TypeScript knows data exists
} else {
console.error(result.error.code); // TypeScript knows error exists
}No more data: T | null with error: string | null where both could theoretically be null or both non-null. The discriminated union enforces exactly one shape at a time.
Branded Types for IDs
This is a pattern I wish I'd adopted years earlier. In every app, you have entity IDs: user IDs, order IDs, product IDs. They're all strings. TypeScript treats them as interchangeable. Your code doesn't care — until a developer passes orderId to a function expecting userId, the query returns nothing, and you spend an hour wondering why the dashboard is blank.
Branded types solve this:
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type ProductId = Brand<string, "ProductId">;
// Constructor functions
function UserId(id: string): UserId {
return id as UserId;
}
function OrderId(id: string): OrderId {
return id as OrderId;
}
function ProductId(id: string): ProductId {
return id as ProductId;
}Now TypeScript enforces the distinction:
async function getOrder(orderId: OrderId) {
return db.order.findUnique({ where: { id: orderId } });
}
async function getUser(userId: UserId) {
return db.user.findUnique({ where: { id: userId } });
}
const userId = UserId("user_abc123");
const orderId = OrderId("order_xyz789");
getOrder(orderId); // OK
getOrder(userId); // Error: Argument of type 'UserId' is not assignable to parameter of type 'OrderId'The __brand property exists only at the type level. At runtime, these are plain strings. Zero overhead. No runtime cost. But the compiler catches every ID mixup.
I combine branded types with Zod for validation at the boundary:
const UserIdSchema = z.string()
.regex(/^user_[a-z0-9]{20}$/)
.transform((val): UserId => UserId(val));
const OrderIdSchema = z.string()
.regex(/^order_[a-z0-9]{20}$/)
.transform((val): OrderId => OrderId(val));Now API route params get validated AND branded in one step. The rest of your codebase works with UserId and OrderId types that can never be confused.
This pattern extends beyond IDs. I use it for any value that has a specific domain meaning:
type EmailAddress = Brand<string, "EmailAddress">;
type Latitude = Brand<number, "Latitude">;
type Longitude = Brand<number, "Longitude">;
type USD = Brand<number, "USD">;
type LKR = Brand<number, "LKR">;
// Prevents mixing currencies in arithmetic
function convertUsdToLkr(amount: USD, rate: number): LKR {
return (amount * rate) as LKR;
}You can't accidentally add USD to LKR. The type system stops you.
Generic API Clients
I build every API client with generics. Not because I love abstraction — because I got tired of maintaining separate fetch wrappers for every endpoint with nearly identical error handling, retry logic, and response parsing.
Here's the pattern:
interface ApiClientConfig {
baseUrl: string;
headers?: Record<string, string>;
timeout?: number;
}
interface RequestOptions<TBody = unknown> {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
path: string;
body?: TBody;
params?: Record<string, string | number | boolean | undefined>;
schema: z.ZodType;
signal?: AbortSignal;
}
class ApiError extends Error {
constructor(
public readonly status: number,
public readonly code: string,
message: string,
public readonly details?: unknown
) {
super(message);
this.name = "ApiError";
}
}
function createApiClient(config: ApiClientConfig) {
async function request<TResponse>(
options: RequestOptions
): Promise<TResponse> {
const url = new URL(options.path, config.baseUrl);
if (options.params) {
Object.entries(options.params).forEach(([key, value]) => {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
});
}
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
config.timeout ?? 10_000
);
try {
const response = await fetch(url.toString(), {
method: options.method,
headers: {
"Content-Type": "application/json",
...config.headers,
},
body: options.body ? JSON.stringify(options.body) : undefined,
signal: options.signal ?? controller.signal,
});
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
throw new ApiError(
response.status,
errorBody.code ?? "UNKNOWN",
errorBody.message ?? `HTTP ${response.status}`,
errorBody.details
);
}
const data = await response.json();
return options.schema.parse(data) as TResponse;
} finally {
clearTimeout(timeoutId);
}
}
return { request };
}Usage is clean and fully typed:
const api = createApiClient({
baseUrl: "https://api.example.com/v1",
headers: { Authorization: `Bearer ${token}` },
});
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
createdAt: z.string().datetime(),
});
type User = z.infer<typeof UserSchema>;
const UsersListSchema = z.object({
data: z.array(UserSchema),
total: z.number(),
cursor: z.string().nullable(),
});
// Fully typed request and response
const users = await api.request<z.infer<typeof UsersListSchema>>({
method: "GET",
path: "/users",
params: { limit: 20, role: "admin" },
schema: UsersListSchema,
});
// users.data is User[], users.total is number, users.cursor is string | nullEvery response is validated at runtime by Zod. If the API changes its response shape, you get a Zod error immediately, not a silent undefined that breaks three components downstream. This pattern has saved me countless times when third-party APIs ship breaking changes without updating their docs.
Type-Safe Environment Variables
Environment variables are the most common source of runtime errors I've debugged in production apps. A missing DATABASE_URL, a typo in NEXT_PUBLIC_STRIPE_KEY, a staging deploy that accidentally uses production credentials. TypeScript can prevent all of these — if you validate environment variables at build time.
Here's the pattern I use in every Next.js project:
// lib/env.ts
import { z } from "zod";
const serverSchema = z.object({
DATABASE_URL: z.string().url(),
DIRECT_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(32),
NEXTAUTH_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_"),
RESEND_API_KEY: z.string().startsWith("re_"),
REDIS_URL: z.string().url(),
});
const clientSchema = z.object({
NEXT_PUBLIC_APP_URL: z.string().url(),
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith("pk_"),
NEXT_PUBLIC_POSTHOG_KEY: z.string().min(1),
});
const serverEnv = serverSchema.safeParse(process.env);
const clientEnv = clientSchema.safeParse({
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
});
if (!serverEnv.success) {
console.error("Missing server environment variables:", serverEnv.error.flatten().fieldErrors);
throw new Error("Invalid server environment variables");
}
if (!clientEnv.success) {
console.error("Missing client environment variables:", clientEnv.error.flatten().fieldErrors);
throw new Error("Invalid client environment variables");
}
export const env = {
...serverEnv.data,
...clientEnv.data,
} as const;This module runs at application startup. If any variable is missing or malformed, the app crashes immediately with a clear error message. Not ten minutes into a user session when someone hits the payment flow and STRIPE_SECRET_KEY is undefined.
// Usage anywhere in your server code
import { env } from "@/lib/env";
const stripe = new Stripe(env.STRIPE_SECRET_KEY);
// Fully typed, guaranteed to exist, guaranteed to start with "sk_"The separation between server and client schemas prevents accidentally leaking server-side secrets to the client bundle. If you try to access env.DATABASE_URL in a Client Component, it won't be in the client schema and you'll get a type error.
I add format validation beyond "is this a string." The Stripe key must start with sk_. The auth secret must be at least 32 characters. The URL must be a valid URL. These constraints catch misconfigurations that simple existence checks miss.
Utility Types I Actually Use
TypeScript ships with dozens of utility types. I use about six of them regularly. Here are the ones that earn their keep in production code.
`Pick` and `Omit` for API boundaries:
// Full database model
interface User {
id: string;
email: string;
passwordHash: string;
name: string;
avatar: string | null;
role: "admin" | "user";
createdAt: Date;
updatedAt: Date;
}
// What the API returns — never expose passwordHash
type PublicUser = Omit<User, "passwordHash">;
// What the profile edit form accepts
type UpdateProfileInput = Pick<User, "name" | "avatar">;`Record` for constrained key-value maps:
type FeatureFlag = "darkMode" | "betaEditor" | "newPricing" | "aiChat";
const featureDefaults: Record<FeatureFlag, boolean> = {
darkMode: true,
betaEditor: false,
newPricing: false,
aiChat: true,
};
// If you add a FeatureFlag, TypeScript forces you to add a default`Extract` and `Exclude` for union manipulation:
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
type WriteMethods = Extract<HttpMethod, "POST" | "PUT" | "PATCH" | "DELETE">;
// "POST" | "PUT" | "PATCH" | "DELETE"
type ReadMethods = Exclude<HttpMethod, WriteMethods>;
// "GET" | "HEAD" | "OPTIONS"`Awaited` for unwrapping Promise types:
async function fetchDashboardData() {
const [users, orders, revenue] = await Promise.all([
getActiveUsers(),
getRecentOrders(),
getMonthlyRevenue(),
]);
return { users, orders, revenue };
}
// Extract the return type without calling the function
type DashboardData = Awaited<ReturnType<typeof fetchDashboardData>>;`Partial` with depth for nested updates:
// Shallow Partial works for flat objects
type UserUpdate = Partial<Pick<User, "name" | "avatar" | "email">>;
// For nested objects, I use a DeepPartial utility
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
interface AppConfig {
theme: { primary: string; secondary: string; mode: "light" | "dark" };
notifications: { email: boolean; push: boolean; sms: boolean };
privacy: { analytics: boolean; marketing: boolean };
}
// Users can update any subset of nested config
function updateConfig(patch: DeepPartial<AppConfig>) {
// merge patch into existing config
}
updateConfig({ theme: { mode: "dark" } }); // valid
updateConfig({ notifications: { push: false } }); // valid`satisfies` (not a utility type, but essential):
The satisfies operator from TypeScript 4.9 is something I use daily. It validates that a value matches a type without widening it:
const ROUTES = {
home: "/",
dashboard: "/dashboard",
settings: "/settings",
profile: "/profile",
} satisfies Record<string, string>;
// Type of ROUTES.home is "/" (literal), not string
// But TypeScript verified all values are stringsWithout satisfies, you'd either lose the literal types (with a type annotation) or lose the validation (with no annotation). satisfies gives you both.
The Patterns I Stopped Using
Not every "best practice" survives contact with production. Here are patterns I used to advocate for and no longer use, with honest reasons why.
Enums. I replaced every enum with as const objects or union types. Enums generate runtime JavaScript, they behave differently from other TypeScript constructs in surprising ways (numeric enums are reverse-mapped, const enum has module boundary issues), and they don't work well with Zod. Unions do everything I need:
// Instead of this
enum OrderStatus {
Pending = "pending",
Confirmed = "confirmed",
Shipped = "shipped",
}
// I use this
const ORDER_STATUS = {
Pending: "pending",
Confirmed: "confirmed",
Shipped: "shipped",
} as const;
type OrderStatus = (typeof ORDER_STATUS)[keyof typeof ORDER_STATUS];
// "pending" | "confirmed" | "shipped"Namespaces. They're a leftover from pre-module TypeScript. ES modules handle everything namespaces do, and they work with tree shaking. I haven't written a namespace in three years.
Abstract classes for contracts. I used to define abstract base classes for service interfaces. Now I use plain TypeScript interfaces. Interfaces don't generate runtime code, they support multiple implementation, and they compose better. The only exception: when I need a base class with shared implementation logic, not just a contract.
Overloaded function signatures. They look clever in library code, but in application code they make functions harder to understand and refactor. I use discriminated union parameters or separate functions instead:
// Instead of overloads
function getUser(id: string): Promise<User>;
function getUser(email: string, byEmail: true): Promise<User>;
// I use separate, clear functions
function getUserById(id: UserId): Promise<User> { /* ... */ }
function getUserByEmail(email: string): Promise<User> { /* ... */ }`namespace` merging and declaration merging. I tried module augmentation to extend third-party types. It works, but it creates invisible type modifications that confuse new team members and break in unexpected ways during upgrades. I wrap third-party types in my own interfaces instead.
Complex conditional types in application code. Library code sometimes needs T extends U ? X : Y chains. Application code almost never does. If I catch myself writing nested conditional types, I step back and simplify the data model. The problem is almost always in the architecture, not in the types.
Key Takeaways
- Strict mode is the foundation. Enable every strict flag TypeScript offers, especially
noUncheckedIndexedAccessandexactOptionalPropertyTypes. The errors you fix today are the bugs you prevent in production.
- Zod bridges the compile-runtime gap. Every trust boundary — API inputs, third-party responses, env vars, URL params — needs runtime validation. Use
z.inferto derive types from schemas, not the other way around.
- Discriminated unions model state correctly. If your data can be in multiple states with different shapes, a discriminated union makes illegal states impossible. Always include an exhaustive check with
assertNever.
- Branded types prevent semantic bugs. The compiler can't tell
userIdfromorderIdunless you brand them. Zero runtime cost, catches real bugs.
- Generic clients reduce duplication. Build one API client with generics and Zod validation. Every endpoint gets type safety and runtime validation for free.
- Validate env vars at startup. Crash early with a clear error, not late with a cryptic one. Separate server and client schemas to prevent secret leakage.
- Kill patterns that don't serve you. Enums, namespaces, overloads, complex conditional types — if they add complexity without catching bugs, replace them with simpler alternatives.
These patterns aren't theoretical. They're running in production right now across projects I've shipped. TypeScript's type system is only as good as your willingness to use it fully. Half-strict TypeScript is just JavaScript with extra syntax.
About the Author
Uvin Vindula is a full-stack and Web3 engineer based in Sri Lanka and the UK, building production applications with TypeScript, Next.js, and Solidity. He runs strict mode on everything, has a zero-tolerance policy for any types, and believes that the best TypeScript code is the code the compiler won't let you write wrong. Follow his work at uvin.lk↗ or reach out at contact@uvin.lk↗.
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.