TypeScript Best Practices for Clean, Maintainable Code

December 8, 2024

TypeScript Best Practices for Clean, Maintainable Code

TypeScript shines when it helps you model reality — not when it forces you to fight types all day. The difference between a codebase that feels typed and one that truly is typed often comes down to a handful of deliberate habits applied consistently. A few small defaults can make a codebase feel calmer, safer, and easier to refactor.

This post is a collection of the patterns and principles I keep coming back to, whether I'm starting a greenfield project or cleaning up an inherited one.

TypeScript code on screen

1. Enable Strict Mode — and Mean It

The single highest-leverage change you can make to any TypeScript project is enabling strict: true in your tsconfig.json. It's not just one flag — it's an umbrella that activates a whole family of checks:

// tsconfig.json { "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true } }

noUncheckedIndexedAccess is particularly underrated. Without it, TypeScript lets you write arr[0].name without any complaint — even if the array might be empty. With it enabled, arr[0] is typed as T | undefined, forcing you to handle the empty case properly.

Turn on strictness early and fix the sharp edges once. Deferring it means inheriting a mountain of type errors later.

2. Prefer Readable Types Over Clever Ones

TypeScript has a powerful type system. Conditional types, mapped types, infer — they're all genuinely useful. But there's a real temptation to write types that are impressive rather than clear.

Clever — but painful to read:

type DeepPartial<T> = T extends object ? { [P in keyof T]?: DeepPartial<T[P]> } : T;

Better — when you can afford it:

// Give the shape a name that communicates intent type UserDraft = { name?: string; email?: string; role?: "admin" | "viewer"; };

The goal is for a teammate (or future you) to read a type definition and immediately understand what it represents. If they need to mentally execute a type-level algorithm to figure it out, the type is too clever.

Rule of thumb: If you can't explain a type in one sentence, it probably needs a name — or needs to be simplified.

3. Use Unions for "One of These", Interfaces for "Shape of This"

This is one of the most practically useful mental models in TypeScript.

Unions are for values that can be one of several distinct things:

type Status = "idle" | "loading" | "success" | "error"; type ApiResponse<T> = | { status: "success"; data: T } | { status: "error"; message: string } | { status: "loading" };

Interfaces are for describing the shape of an object — especially when that shape might be extended or implemented:

interface User { id: string; name: string; email: string; role: "admin" | "viewer"; } interface AdminUser extends User { role: "admin"; permissions: string[]; }

The combination of both is where TypeScript gets genuinely powerful. A discriminated union of interfaces lets you narrow types safely in any branch:

type AuthState = | { kind: "unauthenticated" } | { kind: "authenticating" } | { kind: "authenticated"; user: User }; function greet(state: AuthState) { if (state.kind === "authenticated") { // TypeScript knows `state.user` exists here return `Hello, ${state.user.name}`; } return "Please log in."; }

4. Never Use any as a Shortcut

any is the escape hatch that turns TypeScript back into JavaScript — silently. When you reach for any, you're not just opting out of type-checking in one place; you're creating a hole that can spread.

// ❌ This defeats the entire point function processData(data: any) { return data.value.toUpperCase(); // TypeScript won't catch this if data.value is undefined } // ✅ Use `unknown` and narrow it yourself function processData(data: unknown) { if ( typeof data === "object" && data !== null && "value" in data && typeof (data as { value: unknown }).value === "string" ) { return (data as { value: string }).value.toUpperCase(); } throw new Error("Unexpected data shape"); }

unknown forces you to prove what the data is before you use it. any trusts you without verification. In a production codebase, that trust is future debt always eventually collected.

If you're dealing with external API responses, use a validation library like Zod or Valibot to parse and type them at the boundary:

import { z } from "zod"; const UserSchema = z.object({ id: z.string(), name: z.string(), email: z.string().email(), }); type User = z.infer<typeof UserSchema>; const user = UserSchema.parse(rawApiResponse); // throws if invalid, returns typed User

5. One Pattern Worth Memorizing: the Result Type

Error handling in TypeScript is tricky. try/catch is untyped — TypeScript has no way of knowing what catch (e) contains. Throwing errors as control flow also makes functions harder to compose.

A better pattern: encode success and failure in the return type itself.

type Result<T, E = string> = { ok: true; value: T } | { ok: false; error: E }; export function parseNumber(input: string): Result<number> { const n = Number(input); return Number.isFinite(n) ? { ok: true, value: n } : { ok: false, error: `"${input}" is not a valid number` }; }

At the call site, TypeScript forces you to handle both branches:

const result = parseNumber(userInput); if (result.ok) { console.log("Parsed:", result.value); // `value` is number here } else { console.error("Error:", result.error); // `error` is string here }

No try/catch, no surprises. The function's signature is honest about the fact that it can fail. This pattern scales beautifully for async operations too:

async function fetchUser(id: string): Promise<Result<User>> { try { const res = await fetch(`/api/users/${id}`); if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }; const data = UserSchema.parse(await res.json()); return { ok: true, value: data }; } catch (e) { return { ok: false, error: "Network or parse error" }; } }

6. Use satisfies for Configuration Objects

Introduced in TypeScript 4.9, the satisfies operator is one of the most useful additions for real-world code. It lets you validate that a value matches a type without widening the type inferred from the literal.

type Route = { path: string; label: string; icon?: string; }; // Without `satisfies` — TypeScript infers `navItems` as `Route[]` // and you lose the literal types const navItems: Route[] = [ { path: "/home", label: "Home" }, { path: "/about", label: "About" }, ]; // With `satisfies` — type is checked, but literal types are preserved const navItems = [ { path: "/home", label: "Home" }, { path: "/about", label: "About" }, ] satisfies Route[]; // Now `navItems[0].path` is `string`, but the shape is still validated

This is especially useful for config objects, theme tokens, and route definitions where you want both safety and autocomplete without losing specificity.

7. Keep Function Signatures Honest

A function's signature is its contract. It should tell you what goes in, what comes out, and implicitly — what can go wrong.

// ❌ Lying signature — returns `undefined` on failure but the type says `User` async function getUser(id: string): Promise<User> { const user = await db.find(id); return user; // could be undefined! } // ✅ Honest signature async function getUser(id: string): Promise<User | null> { return db.find(id) ?? null; } // ✅ Even better — pair with Result async function getUser(id: string): Promise<Result<User>> { const user = await db.find(id); if (!user) return { ok: false, error: `User ${id} not found` }; return { ok: true, value: user }; }

When signatures are honest, TypeScript's exhaustiveness checking works for you. You won't forget to handle null because the compiler won't let you.

8. Leverage Template Literal Types for String Patterns

TypeScript's template literal types let you express string patterns at the type level — great for event names, CSS class names, API routes, and more.

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; type ApiVersion = "v1" | "v2"; type ApiRoute = `/${ApiVersion}/${string}`; type EventName<T extends string> = `on${Capitalize<T>}`; type MouseEvents = EventName<"click" | "hover" | "focus">; // → "onClick" | "onHover" | "onFocus"

Combined with mapped types, this becomes a powerful tool for deriving types automatically from a single source of truth:

const EVENTS = ["click", "hover", "focus"] as const; type AppEvents = { [K in (typeof EVENTS)[number] as `on${Capitalize<K>}`]: (e: Event) => void; }; // { onClick: ...; onHover: ...; onFocus: ... }

Wrap-Up

The best TypeScript code reads like good documentation: clear names, predictable shapes, and errors that point you to the fix — not away from it. You don't need to master every corner of the type system to write excellent TypeScript. You need to internalize a few principles and apply them consistently.

To recap:

  • Enable strict mode and keep it on. The short-term pain is worth it.
  • Write readable types, not clever ones. Name your types like you'd name your functions.
  • Use unions and interfaces together — discriminated unions are one of TypeScript's greatest strengths.
  • Treat any as a last resort, not a first tool. Reach for unknown and narrow explicitly.
  • Model failure in return types with the Result pattern. Functions that can fail should say so.
  • Use satisfies to validate shapes without losing literal precision.
  • Keep signatures honest — the compiler is on your side when you are.

TypeScript is a tool for communicating intent — to the compiler, to your teammates, and to your future self. The more honest your types are, the more the compiler can do for you.

GitHub
LinkedIn