Developer Tools

TypeScript Best Practices: Writing Safer, More Maintainable Code

Learn the TypeScript patterns that matter most — strict mode, utility types, generics, discriminated unions, and avoiding common type-system escape hatches.

8 min read

Code editor showing TypeScript on a monitor

TypeScript has moved from optional to essential in most professional JavaScript projects. But there's a wide spectrum between "technically TypeScript" and "TypeScript that actually catches bugs." The difference comes down to a handful of patterns that unlock the type system's real power.

Enable strict mode — always

The single highest-value TypeScript configuration change:

// tsconfig.json
{
  "compilerOptions": {
    "strict": true
  }
}

strict enables a bundle of checks:

  • strictNullChecksnull and undefined are not assignable to other types
  • noImplicitAny — variables can't implicitly have type any
  • strictFunctionTypes — stricter function parameter checking
  • strictPropertyInitialization — class properties must be initialized

Without strictNullChecks, TypeScript misses the most common class of runtime errors. Turn it on from day one — retrofitting it onto a large codebase is painful.

Avoid any — use unknown instead

any is a type-system escape hatch that silences all errors and defeats the purpose of TypeScript:

// ❌ any disables all type checking
function processData(data: any) {
  data.nonExistentMethod(); // No error! Crashes at runtime.
}

// ✅ unknown forces you to narrow the type before using it
function processData(data: unknown) {
  if (typeof data === "string") {
    console.log(data.toUpperCase()); // Safe — narrowed to string
  }
  if (data instanceof Error) {
    console.log(data.message); // Safe — narrowed to Error
  }
}

unknown is the type-safe version of any. It forces you to check what you have before using it. Use it for API responses, JSON parsing, and anything coming from external sources.

Discriminated unions for exhaustive handling

One of TypeScript's most powerful patterns:

type LoadingState = { status: "loading" };
type SuccessState = { status: "success"; data: User[] };
type ErrorState   = { status: "error"; message: string };

type ApiState = LoadingState | SuccessState | ErrorState;

function render(state: ApiState) {
  switch (state.status) {
    case "loading":
      return <Spinner />;
    case "success":
      return <UserList users={state.data} />; // state.data available here
    case "error":
      return <ErrorMessage msg={state.message} />; // state.message available here
    default:
      // TypeScript will error here if you add a new state without handling it
      const _exhaustive: never = state;
      throw new Error(`Unhandled state: ${_exhaustive}`);
  }
}

The never assignment at the end is the exhaustiveness check — if you add a new variant to ApiState without handling it in the switch, TypeScript gives a compile-time error. This is one of the most valuable safety nets in the entire language.

Utility types

TypeScript ships with built-in type transformers:

interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "editor" | "viewer";
  createdAt: Date;
}

// Make all properties optional
type PartialUser = Partial<User>;

// Make all properties required
type RequiredUser = Required<User>;

// Make all properties read-only
type ReadonlyUser = Readonly<User>;

// Pick specific properties
type UserPreview = Pick<User, "id" | "name">;

// Exclude specific properties
type UserWithoutDates = Omit<User, "createdAt">;

// Create a type from an object's values
type UserRole = User["role"]; // "admin" | "editor" | "viewer"

// Make properties nullable
type NullableUser = { [K in keyof User]: User[K] | null };

// Record type: object with specific key and value types
type RolePermissions = Record<UserRole, string[]>;

// Extract/Exclude from union types
type AdminOrEditor = Extract<UserRole, "admin" | "editor">; // "admin" | "editor"
type NonAdmin = Exclude<UserRole, "admin">; // "editor" | "viewer"

Generics for reusable, type-safe code

Generics let you write functions and classes that work with any type while still being fully type-safe:

// Without generics — loses type information
function first(arr: any[]): any {
  return arr[0];
}

// With generics — preserves type
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

const num = first([1, 2, 3]);     // type: number | undefined
const str = first(["a", "b"]);   // type: string | undefined

Constraining generics

// T must have an 'id' property
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id);
}

// K must be a key of T
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 1, name: "Alice", role: "admin" };
const name = getProperty(user, "name");  // type: string
const role = getProperty(user, "role");  // type: string
// getProperty(user, "missing"); // ❌ TypeScript error — "missing" not a key

Type guards and narrowing

// Type predicate — custom type guard
function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "name" in value &&
    "email" in value
  );
}

// Usage
const data: unknown = await fetchUser();
if (isUser(data)) {
  console.log(data.name); // TypeScript knows data is User here
}

For API response validation at runtime, pair TypeScript interfaces with runtime schema validation using Zod. Generate Zod schemas directly from your JSON with our JSON to Zod converter — paste an example JSON response and get a type-safe schema instantly.

Template literal types

TypeScript 4.1+ supports string manipulation at the type level:

type EventName = "click" | "focus" | "blur";
type Handler = `on${Capitalize<EventName>}`; // "onClick" | "onFocus" | "onBlur"

type CSSProperty = "margin" | "padding";
type CSSDirection = "top" | "right" | "bottom" | "left";
type CSSKey = `${CSSProperty}-${CSSDirection}`;
// "margin-top" | "margin-right" | ... | "padding-left"

satisfies operator (TypeScript 4.9+)

Validates that a value matches a type without widening it:

type ColorMap = Record<string, string>;

// ❌ Without satisfies — type widened to Record<string, string>
const colors: ColorMap = {
  primary: "#3b82f6",
  secondary: "#8b5cf6",
};
colors.primary.toUpperCase(); // Works but loses string type

// ✅ With satisfies — validates AND preserves literal types
const colors = {
  primary: "#3b82f6",
  secondary: "#8b5cf6",
} satisfies ColorMap;

colors.primary.toUpperCase(); // ✅ TypeScript knows it's a string
colors.nonexistent; // ❌ Error — key doesn't exist

Generating types from data

Instead of writing TypeScript interfaces by hand from API responses:

  1. Paste a sample JSON response into our JSON to TypeScript converter to get interfaces instantly
  2. Or generate a Zod schema with JSON to Zod — Zod infers TypeScript types automatically via z.infer<typeof schema>
import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  role: z.enum(["admin", "editor", "viewer"]),
});

// Type inferred automatically — no duplication
type User = z.infer<typeof UserSchema>;

// Validates at runtime AND provides TypeScript types
const user = UserSchema.parse(await response.json());

Common anti-patterns to avoid

Anti-pattern Problem Fix
as any Disables type checking Use unknown + narrowing
// @ts-ignore Silences errors without fixing Fix the type error
as SomeType (unsafe cast) Bypasses inference Use type guards
interface for unions Doesn't work for discriminated unions Use type
Overly generic object No structure Define a proper interface
Function as a type No parameter/return info Use specific signature

TypeScript configuration worth knowing

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,  // array[n] returns T | undefined
    "exactOptionalPropertyTypes": true, // distinguish missing vs undefined
    "noImplicitReturns": true,          // all code paths must return
    "noFallthroughCasesInSwitch": true, // no unintentional switch fallthrough
    "verbatimModuleSyntax": true        // explicit import type for type-only imports
  }
}

TypeScript's value is proportional to how strictly you use it. The patterns here — strict mode, discriminated unions, generics, exhaustiveness checks — are what separate TypeScript that finds bugs from TypeScript that just adds syntax.