← Back to Blog

TypeScript Best Practices: Lessons from the Trenches

Practical TypeScript patterns from years of production experience. Learn about strict mode, effective use of generics, utility types, discriminated unions, and why 'any' is almost never the answer.

Sarah Chen
Sarah ChenSenior Full-Stack Developer

I have been writing TypeScript since 2017, back when convincing teams to adopt it meant explaining that no, it is not just "JavaScript with extra steps." These days the conversation has shifted. TypeScript is the default for serious JavaScript projects. The question is no longer whether to use TypeScript, but how to use it well.

After shipping TypeScript code at three startups and one large enterprise, I have opinions. Some of them are even correct. Let me share what actually works in production.

Start Strict, Stay Strict

When I join a new team, the first thing I check is their tsconfig.json. If I see "strict": false, I know we are going to have a conversation.

{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "exactOptionalPropertyTypes": true } }

That strict flag enables a collection of checks that catch real bugs. Let me show you what you get.

strictNullChecks Saves Lives

Without strictNullChecks:

function getUser(id: string) { return users.find(u => u.id === id); } // This compiles fine but crashes at runtime const user = getUser("123"); console.log(user.name); // TypeError: Cannot read property 'name' of undefined

With strictNullChecks, TypeScript knows that find can return undefined:

const user = getUser("123"); console.log(user.name); // Error: Object is possibly 'undefined' // You're forced to handle the null case if (user) { console.log(user.name); // Safe }

At my last startup, we migrated a 50k line codebase to strict mode. We found 847 potential null reference errors. Twelve of them were actively causing production bugs that nobody had connected to the root cause.

noUncheckedIndexedAccess: The Underrated Flag

This one is not part of strict mode, but it should be. Consider this code:

const items = ["a", "b", "c"]; const first = items[0]; // Type: string // But what about this? const tenth = items[10]; // Also Type: string (but actually undefined!)

With noUncheckedIndexedAccess:

const tenth = items[10]; // Type: string | undefined

Now you are forced to handle the case where the index is out of bounds. It is slightly more annoying to work with, but it catches real bugs.

Let TypeScript Do Its Job: Type Inference

One of the most common mistakes I see is over-annotating. TypeScript has excellent type inference—use it.

// Unnecessary annotations const name: string = "Sarah"; const count: number = 42; const items: string[] = ["a", "b", "c"]; // Let inference work const name = "Sarah"; // Type: string const count = 42; // Type: number const items = ["a", "b", "c"]; // Type: string[]

The rule I follow: annotate function parameters and return types, let inference handle the rest.

// Parameters: annotate. Return type: annotate. function calculateTotal(items: CartItem[]): number { // Local variables: let inference work const subtotal = items.reduce((sum, item) => sum + item.price, 0); const tax = subtotal * 0.08; return subtotal + tax; }

Why annotate return types? Because they serve as documentation and catch mistakes:

function getUserRole(user: User): "admin" | "user" | "guest" { if (user.isAdmin) { return "admin"; } // Error: Function lacks ending return statement // This forces you to handle all cases }

Without the return type annotation, this function would silently return undefined for non-admin users.

Generics: Power Without Complexity

Generics intimidate people, but they are just variables for types. Start simple.

The Basics

// A function that works with any type function first<T>(items: T[]): T | undefined { return items[0]; } const firstNumber = first([1, 2, 3]); // Type: number | undefined const firstString = first(["a", "b"]); // Type: string | undefined

TypeScript infers T from the argument. You rarely need to specify it explicitly.

Constrained Generics

Sometimes you need to constrain what types are allowed:

// T must have a 'length' property function longest<T extends { length: number }>(a: T, b: T): T { return a.length >= b.length ? a : b; } longest("hello", "hi"); // Works: strings have length longest([1, 2, 3], [1]); // Works: arrays have length longest(100, 200); // Error: number doesn't have length

Real-World Generic Patterns

Here is a pattern I use constantly—a typed API response wrapper:

interface ApiResponse<T> { data: T; status: number; timestamp: Date; } async function fetchApi<T>(url: string): Promise<ApiResponse<T>> { const response = await fetch(url); const data = await response.json(); return { data: data as T, status: response.status, timestamp: new Date() }; } // Usage interface User { id: string; name: string; email: string; } const response = await fetchApi<User>("/api/users/123"); console.log(response.data.name); // Fully typed

Another pattern—type-safe event emitters:

type EventMap = { userCreated: { userId: string; email: string }; orderPlaced: { orderId: string; total: number }; error: { message: string; code: number }; }; class TypedEmitter<T extends Record<string, unknown>> { private listeners: Partial<{ [K in keyof T]: ((data: T[K]) => void)[] }> = {}; on<K extends keyof T>(event: K, callback: (data: T[K]) => void): void { if (!this.listeners[event]) { this.listeners[event] = []; } this.listeners[event]!.push(callback); } emit<K extends keyof T>(event: K, data: T[K]): void { this.listeners[event]?.forEach(callback => callback(data)); } } const emitter = new TypedEmitter<EventMap>(); emitter.on("userCreated", (data) => { console.log(data.userId); // Typed correctly console.log(data.email); // Typed correctly }); emitter.emit("userCreated", { userId: "123", email: "[email protected]" }); emitter.emit("userCreated", { userId: "123" }); // Error: missing 'email'

Utility Types: Your Type Transformation Toolkit

TypeScript ships with utility types that transform existing types. Learn these—they will save you hours.

Partial and Required

interface User { id: string; name: string; email: string; preferences: { theme: "light" | "dark"; notifications: boolean; }; } // All properties optional type UserUpdate = Partial<User>; // All properties required (useful when extending partial interfaces) type CompleteUser = Required<User>; // Practical use: update function function updateUser(id: string, updates: Partial<User>): User { const existing = getUserById(id); return { ...existing, ...updates }; } updateUser("123", { name: "New Name" }); // Only name is required

Pick and Omit

// Select specific properties type UserCredentials = Pick<User, "email" | "id">; // { email: string; id: string } // Exclude specific properties type PublicUser = Omit<User, "email" | "preferences">; // { id: string; name: string } // Real use case: API response that hides sensitive data function toPublicUser(user: User): PublicUser { const { email, preferences, ...publicData } = user; return publicData; }

Record

// Create an object type with specific keys and value types type UserRoles = Record<string, "admin" | "user" | "guest">; const roles: UserRoles = { "user-1": "admin", "user-2": "user", "user-3": "invalid" // Error: not assignable to "admin" | "user" | "guest" }; // With union keys for exhaustive objects type Permission = "read" | "write" | "delete"; type PermissionFlags = Record<Permission, boolean>; const permissions: PermissionFlags = { read: true, write: true, delete: false // Error if any permission is missing };

Extract and Exclude

type AllEvents = "click" | "scroll" | "mousemove" | "keydown" | "keyup"; // Extract only keyboard events type KeyboardEvents = Extract<AllEvents, "keydown" | "keyup">; // "keydown" | "keyup" // Exclude keyboard events type MouseEvents = Exclude<AllEvents, "keydown" | "keyup">; // "click" | "scroll" | "mousemove"

Discriminated Unions: Make Illegal States Unrepresentable

This is one of the most powerful patterns in TypeScript. The idea: use a literal type property to distinguish between variants.

// Instead of this mess interface ApiResult { success: boolean; data?: User; error?: string; errorCode?: number; } // Use discriminated unions type ApiResult = | { status: "success"; data: User } | { status: "error"; error: string; errorCode: number } | { status: "loading" }; function handleResult(result: ApiResult) { switch (result.status) { case "success": console.log(result.data.name); // TypeScript knows 'data' exists break; case "error": console.log(result.error); // TypeScript knows 'error' exists break; case "loading": console.log("Loading..."); break; } }

The beauty: you cannot access data when the status is "error". TypeScript enforces it.

Exhaustiveness Checking

Want TypeScript to yell at you when you forget a case? Use the never trick:

function assertNever(x: never): never { throw new Error(`Unexpected value: ${x}`); } function handleResult(result: ApiResult) { switch (result.status) { case "success": return result.data; case "error": throw new Error(result.error); // If we forget "loading", TypeScript errors: // Argument of type '{ status: "loading" }' is not assignable to parameter of type 'never' default: return assertNever(result); } }

Now if someone adds a new status to ApiResult, every switch statement that handles it will fail to compile until updated. That is the kind of safety you cannot get with runtime checks.

The Case Against 'any'

Let me be direct: every any in your codebase is a bug waiting to happen.

function processData(data: any) { return data.foo.bar.baz; // No error, crashes at runtime }

When you use any, you are telling TypeScript to look the other way. You lose all the benefits of the type system.

unknown: The Type-Safe Alternative

When you genuinely do not know the type, use unknown:

function processData(data: unknown) { // Error: Object is of type 'unknown' return data.foo.bar.baz; // You must narrow the type first if (typeof data === "object" && data !== null && "foo" in data) { // Now TypeScript knows more about 'data' } }

Type Guards for Runtime Validation

interface User { id: string; name: string; email: string; } function isUser(value: unknown): value is User { return ( typeof value === "object" && value !== null && "id" in value && "name" in value && "email" in value && typeof (value as User).id === "string" && typeof (value as User).name === "string" && typeof (value as User).email === "string" ); } async function fetchUser(id: string): Promise<User> { const response = await fetch(`/api/users/${id}`); const data: unknown = await response.json(); if (!isUser(data)) { throw new Error("Invalid user data from API"); } return data; // TypeScript knows this is User }

For complex validation, use Zod:

import { z } from "zod"; const UserSchema = z.object({ id: z.string(), name: z.string(), email: z.string().email(), }); type User = z.infer<typeof UserSchema>; async function fetchUser(id: string): Promise<User> { const response = await fetch(`/api/users/${id}`); const data = await response.json(); return UserSchema.parse(data); // Throws if invalid }

When 'any' Is Acceptable

I will not pretend any never has a place. Legitimate uses:

  1. Migrating a JavaScript codebase incrementally
  2. Working with genuinely dynamic third-party code
  3. Temporary scaffolding (with a TODO to fix it)

When you must use any, at least scope it tightly:

// Bad: any spreads function processLegacyData(data: any) { return transformData(data); // 'any' infects everything it touches } // Better: contain the any function processLegacyData(data: any): ProcessedData { const validated = validateAndTransform(data); return validated; // Return type is explicit }

Configuration Best Practices

Here is my recommended tsconfig.json for new projects:

{ "compilerOptions": { "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "noUncheckedIndexedAccess": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "exactOptionalPropertyTypes": true, "skipLibCheck": true, "declaration": true, "declarationMap": true, "sourceMap": true, "outDir": "./dist", "rootDir": "./src" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] }

For frontend projects, adjust lib to include DOM types:

"lib": ["ES2022", "DOM", "DOM.Iterable"]

Practical Patterns I Use Daily

Branded Types

Prevent mixing up primitive types that mean different things:

type UserId = string & { readonly brand: unique symbol }; type OrderId = string & { readonly brand: unique symbol }; function createUserId(id: string): UserId { return id as UserId; } function createOrderId(id: string): OrderId { return id as OrderId; } function getUser(id: UserId): User { /* ... */ } function getOrder(id: OrderId): Order { /* ... */ } const userId = createUserId("user-123"); const orderId = createOrderId("order-456"); getUser(userId); // Works getUser(orderId); // Error: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'

Builder Pattern with Types

class QueryBuilder<T extends object> { private conditions: Partial<T> = {}; where<K extends keyof T>(key: K, value: T[K]): this { this.conditions[key] = value; return this; } build(): Partial<T> { return { ...this.conditions }; } } interface UserQuery { name: string; age: number; active: boolean; } const query = new QueryBuilder<UserQuery>() .where("name", "Sarah") .where("age", 30) .where("active", "yes") // Error: '"yes"' is not assignable to type 'boolean' .build();

Wrapping Up

TypeScript is a tool. Like any tool, its value depends on how you use it. The patterns I have shared here are not academic—they are battle-tested solutions to problems I have encountered shipping real products.

Start with strict mode. Let inference work. Use discriminated unions. Avoid any. Learn the utility types.

The initial investment pays off. Fewer bugs in production. Easier refactoring. Better documentation through types. Code that tells you when it is wrong before your users do.

That is the promise of TypeScript, and in my experience, it delivers.

Sarah Chen
Written bySarah ChenSenior Full-Stack Developer
Read more articles