Skip to article

TypeScript in 2026: The Complete Guide — From Fundamentals to Advanced Patterns

A comprehensive deep-dive into modern TypeScript development covering the type system, generics, utility types, decorators, performance patterns, and the latest features through TypeScript 5.x.

WA
TypeScript in 2026: The Complete Guide — From Fundamentals to Advanced Patterns
§ 01

#Introduction: Why TypeScript Is Non-Negotiable in 2026

TypeScript is no longer a preference — it is an industry standard. According to the 2025 State of JavaScript survey, TypeScript adoption reached 91% among professional developers, up from 78% in 2022 [1]. Major frameworks including Angular, Next.js, Remix, NestJS, and tRPC are TypeScript-first. Even Node.js 22+ ships with experimental native TypeScript stripping.

#What TypeScript Gives You

  • Compile-time error detection — catch bugs before they reach production
  • IDE superpowers — autocomplete, inline docs, refactoring confidence
  • Self-documenting code — function signatures tell you everything
  • Safer refactoring — rename a type and TypeScript tells you every callsite that breaks
  • Team scalability — new developers understand unfamiliar code faster

#TypeScript Is Not Just "JavaScript with Types"

TypeScript's type system is Turing-complete [2]. You can encode complex invariants, model domain logic, and eliminate entire categories of runtime errors — all at zero runtime cost, since types are erased at compile time.

typescript
// Without TypeScript — runtime surprise
function formatCurrency(amount, currency) {
  return `${currency}${amount.toFixed(2)}`;
}
formatCurrency("100", "USD"); // Runtime: TypeError — "100".toFixed is not a function

// With TypeScript — caught at compile time
function formatCurrency(amount: number, currency: string): string {
  return `${currency}${amount.toFixed(2)}`;
}
formatCurrency("100", "USD"); // ❌ Compile error: Argument of type 'string' is not assignable to parameter of type 'number'

Caption: TypeScript shifts error detection from runtime (your production server) to compile time (your local machine or CI pipeline). The earlier a bug is caught, the cheaper it is to fix.

§ 02

#TypeScript 5.x — What's New

TypeScript 5.0 through 5.7 introduced a series of features that make the type system more expressive and the developer experience significantly better.

#const Type Parameters

typescript
// TypeScript 5.0+
// Without const — types are widened
function identity<T>(value: T): T {
  return value;
}
const result = identity(['a', 'b', 'c']);
// result: string[]  — lost the tuple information

// With const — types are preserved as literals
function identityConst<const T>(value: T): T {
  return value;
}
const result2 = identityConst(['a', 'b', 'c']);
// result2: readonly ['a', 'b', 'c']  — preserved as tuple literal

Caption: const type parameters behave like as const applied to the inferred type argument. This is particularly useful for functions that work with route definitions, configuration objects, or anything where literal types matter.

#Variadic Tuple Improvements

typescript
// TypeScript 5.x — spread in the middle of tuples
type Strings = [string, string];
type Numbers = [number, number];

type Combined = [...Strings, boolean, ...Numbers];
// type Combined = [string, string, boolean, number, number]

// Practical use: typed function pipelines
function pipeline<T, U, V>(
  value: T,
  fn1: (v: T) => U,
  fn2: (v: U) => V
): V {
  return fn2(fn1(value));
}

#using — The Explicit Resource Management Keyword

typescript
// TypeScript 5.2+ — Symbol.dispose protocol
class DatabaseConnection {
  private connection: Connection;

  constructor(url: string) {
    this.connection = openConnection(url);
    console.log('Connection opened');
  }

  [Symbol.dispose]() {
    this.connection.close();
    console.log('Connection closed automatically');
  }

  query(sql: string) {
    return this.connection.execute(sql);
  }
}

// using — automatically calls Symbol.dispose at end of block
async function getUsers() {
  using db = new DatabaseConnection(process.env.DATABASE_URL!);
  // db.connection is alive here
  const users = await db.query('SELECT * FROM users');
  return users;
  // db[Symbol.dispose]() is automatically called here — connection closed
}
// No try/finally needed, no forgetting to close resources

Caption: The using declaration is TypeScript's equivalent of C#'s using or Python's with statement. It guarantees resource cleanup even if an exception is thrown, eliminating a common source of resource leaks with database connections, file handles, and timers.

#Improved infer with extends

typescript
// TypeScript 5.4+
type GetReturnType<T> = T extends (...args: never[]) => infer R ? R : never;

// With conditional infer constraints
type UnwrapPromise<T> = T extends Promise<infer R extends string> ? R : T;
// R is now constrained to string — no need for a second conditional check
§ 03

#The Type System: Foundations You Must Master

#Primitive Types and Literal Types

typescript
// Primitives
let name: string = "Alice";
let age: number = 30;
let active: boolean = true;
let id: bigint = 9007199254740991n;
let sym: symbol = Symbol('unique');

// Literal types — the exact value is the type
let direction: 'north' | 'south' | 'east' | 'west' = 'north';
let statusCode: 200 | 201 | 400 | 401 | 404 | 500 = 200;
let version: 1 | 2 | 3 = 1;

// Template literal types — combine strings at the type level
type EventName = 'click' | 'focus' | 'blur';
type HandlerName = `on${Capitalize<EventName>}`;
// type HandlerName = "onClick" | "onFocus" | "onBlur"

type CSSProperty = `${string}-${string}`;
// Matches "background-color", "font-size", etc.

Caption: Literal types narrow the set of valid values to exact constants. This is the foundation of discriminated unions, exhaustive switch statements, and type-safe event systems.

#Union and Intersection Types

typescript
// Union — A OR B
type StringOrNumber = string | number;
type SuccessOrError =
  | { status: 'success'; data: User }
  | { status: 'error'; message: string };

function handleResult(result: SuccessOrError) {
  if (result.status === 'success') {
    console.log(result.data.name); // TypeScript knows .data exists here
  } else {
    console.log(result.message); // TypeScript knows .message exists here
  }
}

// Intersection — A AND B (merges types)
type AdminUser = User & { role: 'admin'; permissions: string[] };

type WithTimestamps<T> = T & {
  createdAt: Date;
  updatedAt: Date;
};

type ProductWithTimestamps = WithTimestamps<Product>;
// Has all Product fields + createdAt + updatedAt

Caption: Discriminated unions (unions where each member has a unique literal field like status) are the most powerful pattern in TypeScript. They allow exhaustive handling of all cases, and TypeScript narrows the type automatically inside each branch.

#Type Narrowing

TypeScript narrows types based on control flow analysis:

typescript
type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'rectangle'; width: number; height: number }
  | { kind: 'triangle'; base: number; height: number };

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2; // TypeScript knows: { kind: 'circle', radius: number }
    case 'rectangle':
      return shape.width * shape.height;  // TypeScript knows: { kind: 'rectangle', width, height }
    case 'triangle':
      return 0.5 * shape.base * shape.height;
    default:
      // Exhaustiveness check — if you add a new shape and forget to handle it,
      // TypeScript will error here at compile time
      const _exhaustive: never = shape;
      throw new Error(`Unhandled shape: ${JSON.stringify(_exhaustive)}`);
  }
}

Caption: Assigning to never in the default case is the canonical exhaustiveness check. If you add a new shape variant but forget to handle it in the switch, TypeScript will raise a compile error on the never assignment — before any code runs.

#Type Guards

typescript
// typeof guard
function padValue(value: string | number, padding: number): string {
  if (typeof value === 'string') {
    return value.padStart(padding); // value: string
  }
  return String(value).padStart(padding); // value: number
}

// instanceof guard
function formatError(error: unknown): string {
  if (error instanceof Error) {
    return `${error.name}: ${error.message}`; // error: Error
  }
  if (typeof error === 'string') {
    return error; // error: string
  }
  return JSON.stringify(error);
}

// User-defined type guard with `is`
function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    'name' in value &&
    typeof (value as User).id === 'string'
  );
}

// Assertion function — throws if condition is false
function assertUser(value: unknown): asserts value is User {
  if (!isUser(value)) {
    throw new TypeError(`Expected User, got: ${JSON.stringify(value)}`);
  }
}

// Usage
const data: unknown = await fetchUser(id);
assertUser(data); // throws if data is not a User
console.log(data.name); // TypeScript knows data is User here

Caption: The asserts value is T signature tells TypeScript that if the function returns (without throwing), the value is guaranteed to be type T. This is more ergonomic than an if (!isUser(data)) throw new Error(...) everywhere.

§ 04

#Generics: Writing Reusable, Type-Safe Code

Generics are parameters for types — they let you write one function that works correctly with many different types.

#Generic Functions

typescript
// Without generics — loses type information
function first(arr: unknown[]): unknown {
  return arr[0];
}
const val = first([1, 2, 3]); // val: unknown — not useful

// With generics — type flows through
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}
const num = first([1, 2, 3]);     // num: number | undefined ✅
const str = first(['a', 'b']);    // str: string | undefined ✅

#Generic Constraints

typescript
// Constrain T to objects with a specific shape
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: '1', name: 'Alice', age: 30 };
const name = getProperty(user, 'name');  // type: string ✅
const age  = getProperty(user, 'age');   // type: number ✅
const bad  = getProperty(user, 'email'); // ❌ Compile error: "email" not in keyof user

// Multiple constraints
function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
  return { ...obj1, ...obj2 };
}

const merged = merge({ name: 'Alice' }, { age: 30 });
// merged: { name: string } & { age: number }
console.log(merged.name); // ✅
console.log(merged.age);  // ✅

#Generic Classes

typescript
class Repository<T extends { id: string }> {
  private store = new Map<string, T>();

  add(item: T): T {
    this.store.set(item.id, item);
    return item;
  }

  findById(id: string): T | undefined {
    return this.store.get(id);
  }

  findAll(): T[] {
    return Array.from(this.store.values());
  }

  update(id: string, patch: Partial<Omit<T, 'id'>>): T | undefined {
    const existing = this.store.get(id);
    if (!existing) return undefined;
    const updated = { ...existing, ...patch };
    this.store.set(id, updated);
    return updated;
  }

  delete(id: string): boolean {
    return this.store.delete(id);
  }
}

// Fully typed for each entity
const userRepo = new Repository<User>();
const productRepo = new Repository<Product>();

userRepo.add({ id: '1', name: 'Alice', email: 'alice@example.com' });
userRepo.update('1', { name: 'Alice Smith' }); // ✅ Only non-id fields
productRepo.add({ id: 'p1', name: 'Keyboard', price: 79.99 });

Caption: The constraint T extends { id: string } ensures the repository only works with entities that have a string id field — enforced at the call site, not at runtime. Partial<Omit<T, 'id'>> creates a type for update patches that excludes the id field and makes all other fields optional.

#Generic Interfaces for API Design

typescript
// A type-safe Result type — avoids throwing errors across layers
type Result<T, E = Error> =
  | { success: true; data: T }
  | { success: false; error: E };

async function safeJsonFetch<T>(url: string): Promise<Result<T>> {
  try {
    const res = await fetch(url);
    if (!res.ok) {
      return { success: false, error: new Error(`HTTP ${res.status}: ${res.statusText}`) };
    }
    const data: T = await res.json();
    return { success: true, data };
  } catch (e) {
    return { success: false, error: e instanceof Error ? e : new Error(String(e)) };
  }
}

// Usage — forced to handle both cases
const result = await safeJsonFetch<User[]>('/api/users');

if (result.success) {
  renderUsers(result.data);  // result.data: User[]
} else {
  showError(result.error.message); // result.error: Error
}

Caption: The Result<T, E> pattern (borrowed from Rust and functional programming) makes error handling explicit in the type signature. The caller cannot ignore the error case — TypeScript will complain if you access data without first checking success.

§ 05

#Utility Types: The Standard Library of Types

TypeScript ships with a set of built-in generic types that transform other types. These are the ones you will use daily.

#The Essential Utility Types

typescript
interface User {
  id: string;
  name: string;
  email: string;
  password: string;
  role: 'admin' | 'user' | 'moderator';
  createdAt: Date;
}

// Partial — all fields become optional (useful for update DTOs)
type UpdateUserDTO = Partial<User>;
// { id?: string; name?: string; email?: string; ... }

// Required — all fields become required (opposite of Partial)
type RequiredUser = Required<User>;

// Pick — select specific fields
type UserPublic = Pick<User, 'id' | 'name' | 'role'>;
// { id: string; name: string; role: 'admin' | 'user' | 'moderator' }

// Omit — exclude specific fields
type UserWithoutPassword = Omit<User, 'password'>;
// Everything except password — safe to send to clients

// Readonly — all fields become readonly
type ImmutableUser = Readonly<User>;

// Record — construct object type from keys and value type
type RolePermissions = Record<User['role'], string[]>;
// { admin: string[]; user: string[]; moderator: string[] }

// Extract / Exclude — filter union members
type AdminOrMod = Extract<User['role'], 'admin' | 'moderator'>;
// 'admin' | 'moderator'

type NonAdmin = Exclude<User['role'], 'admin'>;
// 'user' | 'moderator'

// ReturnType / Parameters — introspect function types
type FetchUserReturn = ReturnType<typeof fetchUser>;
// Promise<User>

type FetchUserParams = Parameters<typeof fetchUser>;
// [id: string]

// Awaited — unwrap Promise
type ResolvedUser = Awaited<ReturnType<typeof fetchUser>>;
// User

Caption: Omit<User, 'password'> is one of the most common patterns in web development — it creates a type safe for sending over the wire, where sensitive fields are stripped at the type level. If you add a new sensitive field to User, you must explicitly decide whether to include it in the public type.

#Building Custom Utility Types

typescript
// Deep Partial — recursively makes all nested fields optional
type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> }
  : T;

// Deep Readonly — recursively makes all fields readonly
type DeepReadonly<T> = T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;

// NonNullable — removes null and undefined from a type
type Email = string | null | undefined;
type SafeEmail = NonNullable<Email>; // string

// Mutable — removes readonly from all fields
type Mutable<T> = { -readonly [K in keyof T]: T[K] };

// Optional — makes specific keys optional (the opposite of Required<Pick<T, K>>)
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

type UserCreationDTO = Optional<User, 'id' | 'createdAt'>;
// id and createdAt are optional, everything else is required

Caption: Custom utility types are mapped types — they use [K in keyof T] to iterate over every key in a type and transform the value. The -readonly and -? modifiers remove existing modifiers, enabling types like Mutable and reverse-Optional.

§ 06

#Advanced Type Patterns

#Conditional Types

typescript
// Basic conditional type: T extends U ? TrueType : FalseType
type IsArray<T> = T extends unknown[] ? true : false;
type A = IsArray<string[]>; // true
type B = IsArray<string>;   // false

// Distributive conditional types — distributes over union members
type Flatten<T> = T extends Array<infer Item> ? Item : T;
type C = Flatten<string[]>;          // string
type D = Flatten<number | string[]>; // number | string

// Inferring types within conditionals
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
type E = UnpackPromise<Promise<User>>; // User
type F = UnpackPromise<string>;        // string (not wrapped in Promise)

// Deeply unwrap nested promises
type Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T;

#Template Literal Types for Type-Safe Strings

typescript
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
type APIVersion = 'v1' | 'v2';
type Resource = 'users' | 'products' | 'orders';

// Build valid API endpoint strings at the type level
type APIEndpoint = `/${APIVersion}/${Resource}`;
// "/v1/users" | "/v1/products" | "/v1/orders" | "/v2/users" | ...

// Type-safe CSS-in-JS
type CSSUnit = 'px' | 'rem' | 'em' | '%' | 'vw' | 'vh';
type CSSValue = `${number}${CSSUnit}`;

function setWidth(value: CSSValue): void {
  document.body.style.width = value;
}

setWidth('100px');   // ✅
setWidth('1.5rem');  // ✅
setWidth('100');     // ❌ Compile error: not a valid CSSValue

// Event map with type-safe event names
type EventMap = {
  'user:created': { user: User };
  'user:deleted': { userId: string };
  'order:placed': { order: Order };
};

class TypedEventEmitter {
  emit<K extends keyof EventMap>(event: K, payload: EventMap[K]): void {
    // implementation
  }

  on<K extends keyof EventMap>(
    event: K,
    handler: (payload: EventMap[K]) => void
  ): void {
    // implementation
  }
}

const emitter = new TypedEventEmitter();
emitter.on('user:created', ({ user }) => console.log(user.name)); // ✅ Fully typed
emitter.emit('user:created', { userId: '1' }); // ❌ Wrong payload shape

Caption: Template literal types allow TypeScript to validate string formats at compile time — not with regex at runtime. The typed event emitter pattern eliminates the need for cast-heavy as SomeType workarounds when handling events.

#Mapped Types for Schema Validation

typescript
// Build a Zod-like schema validator type
type Validator<T> = {
  [K in keyof T]: T[K] extends string
    ? { type: 'string'; minLength?: number; maxLength?: number }
    : T[K] extends number
    ? { type: 'number'; min?: number; max?: number }
    : T[K] extends boolean
    ? { type: 'boolean' }
    : { type: 'unknown' };
};

type UserSchema = Validator<Pick<User, 'name' | 'email'>>;
/* {
  name: { type: 'string'; minLength?: number; maxLength?: number };
  email: { type: 'string'; minLength?: number; maxLength?: number };
} */

// Builder pattern with mapped types
type Builder<T> = {
  [K in keyof T as `set${Capitalize<string & K>}`]: (value: T[K]) => Builder<T>;
} & {
  build(): T;
};

#The satisfies Operator

Introduced in TypeScript 4.9, satisfies validates a value against a type without widening it:

typescript
type Config = {
  host: string;
  port: number;
  db: string;
};

// Without satisfies — type is widened to Config, losing literal types
const config: Config = {
  host: 'localhost',
  port: 5432,
  db: 'mydb',
};
// config.port is number — lost the literal 5432

// With satisfies — validated against Config, but keeps literal types
const config2 = {
  host: 'localhost',
  port: 5432,
  db: 'mydb',
} satisfies Config;
// config2.port is 5432 (literal) — AND TypeScript validated the shape ✅

// Practical use: palette map
const palette = {
  red: [255, 0, 0],
  green: '#00ff00',
  blue: [0, 0, 255],
} satisfies Record<string, string | number[]>;

// palette.red is number[] — not string | number[]
// palette.green is string — not string | number[]

Caption: satisfies solves a long-standing tension: you want TypeScript to validate that an object matches a type, but you also want to keep the narrower inferred type for downstream use. With satisfies, you get both validation and inference without a type annotation.

§ 07

#Decorators: The Stable Standard

TypeScript 5.0 shipped Stage 3 TC39 decorators — a stable, standards-compliant implementation. These replace the older experimentalDecorators flag.

typescript
// Class decorator
function singleton<T extends { new(...args: unknown[]): unknown }>(constructor: T) {
  let instance: InstanceType<T>;
  return class extends constructor {
    constructor(...args: unknown[]) {
      super(...args);
      if (instance) return instance;
      instance = this as InstanceType<T>;
    }
  };
}

@singleton
class DatabasePool {
  constructor(public readonly maxConnections: number = 10) {
    console.log('Pool created');
  }
}

const pool1 = new DatabasePool(20);
const pool2 = new DatabasePool(30);
console.log(pool1 === pool2); // true — same instance

// Method decorator
function log(target: unknown, context: ClassMethodDecoratorContext) {
  const methodName = String(context.name);
  return function(this: unknown, ...args: unknown[]) {
    console.log(`[${methodName}] called with:`, args);
    const result = (target as Function).apply(this, args);
    console.log(`[${methodName}] returned:`, result);
    return result;
  };
}

class UserService {
  @log
  async createUser(name: string, email: string): Promise<User> {
    // implementation
    return { id: crypto.randomUUID(), name, email, role: 'user', createdAt: new Date() } as User;
  }
}

// Field decorator for validation
function minLength(min: number) {
  return function(_: undefined, context: ClassFieldDecoratorContext) {
    return function(this: unknown, value: string): string {
      if (value.length < min) {
        throw new RangeError(
          `${String(context.name)} must be at least ${min} characters, got ${value.length}`
        );
      }
      return value;
    };
  };
}

class CreateUserDTO {
  @minLength(2)
  name: string = '';

  @minLength(5)
  email: string = '';
}

Caption: TC39 Stage 3 decorators use ClassMethodDecoratorContext and ClassFieldDecoratorContext instead of the older PropertyDescriptor-based API. If you are migrating from experimentalDecorators, note that the signatures are different — the new API is cleaner but not backward compatible.

§ 08

#TypeScript with React

#Typing Components

typescript
import { ReactNode, ComponentPropsWithoutRef, forwardRef } from 'react';

// Basic component props
interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'sm' | 'md' | 'lg';
  loading?: boolean;
  children: ReactNode;
  onClick?: () => void;
}

// Extend native HTML element props
interface InputProps extends ComponentPropsWithoutRef<'input'> {
  label: string;
  error?: string;
  hint?: string;
}

// forwardRef with TypeScript
const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, hint, ...props }, ref) => (
    <div>
      <label>{label}</label>
      <input ref={ref} aria-invalid={!!error} aria-describedby={hint ? 'hint' : undefined} {...props} />
      {hint && <p id="hint">{hint}</p>}
      {error && <p role="alert">{error}</p>}
    </div>
  )
);
Input.displayName = 'Input';

Caption: Extending ComponentPropsWithoutRef<'input'> gives the component all native HTML input props (like value, onChange, disabled, placeholder) automatically. The ...props spread passes them to the underlying element without having to enumerate each one.

#Typing Hooks

typescript
// Generic hook with full type inference
function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setValue = useCallback(
    (value: T | ((prev: T) => T)) => {
      try {
        const valueToStore = value instanceof Function ? value(storedValue) : value;
        setStoredValue(valueToStore);
        localStorage.setItem(key, JSON.stringify(valueToStore));
      } catch (error) {
        console.error(error);
      }
    },
    [key, storedValue]
  );

  return [storedValue, setValue];
}

// Usage — T is inferred from initialValue
const [user, setUser] = useLocalStorage<User | null>('user', null);
const [theme, setTheme] = useLocalStorage('theme', 'dark');
// theme: string, setTheme: (value: string | ((prev: string) => string)) => void

#Typing Context

typescript
import { createContext, useContext, useState, ReactNode } from 'react';

interface AuthContextValue {
  user: User | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  isLoading: boolean;
}

// Avoid the need to check for null in every consumer
const AuthContext = createContext<AuthContextValue | null>(null);

export function useAuth(): AuthContextValue {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(false);

  async function login(email: string, password: string) {
    setIsLoading(true);
    try {
      const user = await authService.login(email, password);
      setUser(user);
    } finally {
      setIsLoading(false);
    }
  }

  function logout() {
    setUser(null);
    authService.logout();
  }

  return (
    <AuthContext.Provider value={{ user, login, logout, isLoading }}>
      {children}
    </AuthContext.Provider>
  );
}

Caption: Initializing context with null and throwing in the hook if it is missing is safer than providing a fake default value. If a component calls useAuth() outside of AuthProvider, it crashes immediately with a clear error rather than silently receiving stale or empty data.

§ 09

#TypeScript with Node.js & Express

#Setting Up a Type-Safe Express App

typescript
// src/types/express.d.ts — augment Express types
declare global {
  namespace Express {
    interface Request {
      user?: User; // Added by auth middleware
      requestId: string;
    }
  }
}

// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';

export function requireAuth(req: Request, res: Response, next: NextFunction): void {
  const token = req.headers.authorization?.split(' ')[1];

  if (!token) {
    res.status(401).json({ error: 'No token provided' });
    return;
  }

  try {
    const payload = verifyJWT(token);
    req.user = payload.user; // TypeScript knows req.user is User (from declaration merging)
    next();
  } catch {
    res.status(401).json({ error: 'Invalid token' });
  }
}

#Type-Safe Route Handlers

typescript
import { Request, Response } from 'express';
import { z } from 'zod';

// Define schemas as the single source of truth
const CreateProductSchema = z.object({
  name: z.string().min(1).max(100),
  price: z.number().positive(),
  description: z.string().optional(),
  categoryId: z.string().uuid(),
});

const ProductParamsSchema = z.object({
  id: z.string().uuid(),
});

// Infer TypeScript types from Zod schemas — no duplication
type CreateProductBody = z.infer<typeof CreateProductSchema>;
type ProductParams = z.infer<typeof ProductParamsSchema>;

// A small helper to validate and type requests
function validate<TBody, TParams>(
  bodySchema: z.ZodType<TBody>,
  paramsSchema?: z.ZodType<TParams>
) {
  return (
    req: Request,
    res: Response,
    next: NextFunction
  ) => {
    const bodyResult = bodySchema.safeParse(req.body);
    if (!bodyResult.success) {
      res.status(400).json({ errors: bodyResult.error.flatten() });
      return;
    }
    req.body = bodyResult.data;

    if (paramsSchema) {
      const paramsResult = paramsSchema.safeParse(req.params);
      if (!paramsResult.success) {
        res.status(400).json({ errors: paramsResult.error.flatten() });
        return;
      }
      req.params = paramsResult.data as Record<string, string>;
    }

    next();
  };
}

// Usage
router.post(
  '/products',
  requireAuth,
  validate(CreateProductSchema),
  async (req: Request<{}, {}, CreateProductBody>, res: Response) => {
    const product = await productService.create(req.body);
    res.status(201).json(product);
  }
);

Caption: Using z.infer<typeof Schema> means your TypeScript types and your validation logic never go out of sync — they are derived from the same definition. Add a new required field to the Zod schema and TypeScript will propagate that requirement everywhere.

#Type-Safe Database Queries with Prisma

typescript
import { PrismaClient, Prisma } from '@prisma/client';

const prisma = new PrismaClient();

// Prisma generates types from your schema automatically
// PrismaClient provides full IntelliSense for all queries

async function getUserWithOrders(userId: string) {
  return prisma.user.findUnique({
    where: { id: userId },
    include: {
      orders: {
        include: { items: { include: { product: true } } },
        orderBy: { createdAt: 'desc' },
        take: 10,
      },
    },
  });
  // Return type: (User & { orders: (Order & { items: (OrderItem & { product: Product })[] })[] }) | null
  // Fully typed — no manual type definition needed
}

// Type-safe where clauses
type UserWhereInput = Prisma.UserWhereInput;

async function searchUsers(filters: UserWhereInput) {
  return prisma.user.findMany({
    where: filters,
    select: { id: true, name: true, email: true, role: true },
  });
}

Caption: Prisma generates TypeScript types from your database schema. Every query, filter, and return type is fully inferred — including nested relations. This eliminates an entire category of runtime errors caused by incorrect field names or mismatched types.

§ 10

#TypeScript Configuration Deep Dive

json
{
  "compilerOptions": {
    // Target & Module
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],

    // Output
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,

    // Strictness — enable ALL of these
    "strict": true,               // Enables all strict checks below
    "noUncheckedIndexedAccess": true,   // array[i] returns T | undefined
    "exactOptionalPropertyTypes": true,  // { x?: string } !== { x: string | undefined }
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true,

    // Extra checks
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitReturns": true,
    "forceConsistentCasingInFileNames": true,

    // Features
    "useDefineForClassFields": true,
    "experimentalDecorators": false,    // Use TC39 decorators (TS 5.0+)
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "resolveJsonModule": true,
    "isolatedModules": true,            // Required for esbuild/SWC/Vite

    // Path aliases (update baseUrl accordingly)
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}

Caption: noUncheckedIndexedAccess is the single most impactful strict option not included in "strict": true. It adds | undefined to every array index and object index signature access, forcing you to handle the case where an element does not exist — one of the most common sources of runtime TypeError in TypeScript codebases.

#Understanding strict: true

strict: true is a shorthand that enables several individual flags:

Flag

What It Catches

strictNullChecks

Prevents accessing properties on null or undefined

strictFunctionTypes

Catches contravariant function parameter mismatches

strictBindCallApply

Types bind, call, and apply correctly

strictPropertyInitialization

Ensures class fields are initialized in constructor

noImplicitAny

Bans any from being inferred implicitly

noImplicitThis

Bans this with an implicit any type

alwaysStrict

Emits "use strict" in all compiled files

§ 11

#Type-Safe API Patterns

#tRPC — End-to-End Type Safety Without Code Generation

typescript
// server/router.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

export const appRouter = t.router({
  users: t.router({
    list: t.procedure
      .input(z.object({ page: z.number().default(1), limit: z.number().default(20) }))
      .query(async ({ input }) => {
        const { page, limit } = input;
        const [users, total] = await Promise.all([
          db.users.findMany({ skip: (page - 1) * limit, take: limit }),
          db.users.count(),
        ]);
        return { users, total, page, totalPages: Math.ceil(total / limit) };
      }),

    create: t.procedure
      .input(z.object({ name: z.string(), email: z.string().email() }))
      .mutation(async ({ input }) => {
        return db.users.create({ data: input });
      }),
  }),
});

export type AppRouter = typeof appRouter;

// client/api.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/router';

export const trpc = createTRPCReact<AppRouter>();

// In a React component — fully typed, no separate type definitions
function UserList() {
  const { data, isLoading } = trpc.users.list.useQuery({ page: 1 });
  // data is typed as { users: User[], total: number, page: number, totalPages: number }

  if (isLoading) return <Spinner />;

  return (
    <ul>
      {data?.users.map(user => (
        <li key={user.id}>{user.name} — {user.email}</li>
      ))}
    </ul>
  );
}

Caption: tRPC achieves end-to-end type safety without any code generation step. The client imports the AppRouter type directly from the server — TypeScript infers procedure input and output types across the network boundary at zero runtime cost.

#Typed Fetch Wrapper

typescript
// A fully typed fetch wrapper for REST APIs
interface APIConfig {
  baseURL: string;
  headers?: Record<string, string>;
}

class TypedAPIClient {
  constructor(private config: APIConfig) {}

  private async request<T>(
    method: string,
    path: string,
    options?: { body?: unknown; params?: Record<string, string> }
  ): Promise<T> {
    const url = new URL(path, this.config.baseURL);

    if (options?.params) {
      Object.entries(options.params).forEach(([k, v]) => url.searchParams.set(k, v));
    }

    const res = await fetch(url.toString(), {
      method,
      headers: {
        'Content-Type': 'application/json',
        ...this.config.headers,
      },
      body: options?.body ? JSON.stringify(options.body) : undefined,
    });

    if (!res.ok) {
      const error = await res.json().catch(() => ({ message: res.statusText }));
      throw new APIError(res.status, error.message);
    }

    return res.json() as Promise<T>;
  }

  get<T>(path: string, params?: Record<string, string>) {
    return this.request<T>('GET', path, { params });
  }

  post<TBody, TResponse>(path: string, body: TBody) {
    return this.request<TResponse>('POST', path, { body });
  }

  put<TBody, TResponse>(path: string, body: TBody) {
    return this.request<TResponse>('PUT', path, { body });
  }

  delete<T>(path: string) {
    return this.request<T>('DELETE', path);
  }
}

// Usage
const api = new TypedAPIClient({ baseURL: 'https://api.example.com' });

const users = await api.get<User[]>('/users', { page: '1' });
const newUser = await api.post<CreateUserBody, User>('/users', { name: 'Alice', email: 'alice@example.com' });
§ 12

#Testing TypeScript Code

#Vitest with Full TypeScript Support

typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov'],
      exclude: ['**/*.d.ts', '**/index.ts'],
    },
  },
  resolve: {
    alias: { '@': resolve(__dirname, './src') },
  },
});
typescript
// src/utils/currency.test.ts
import { describe, it, expect } from 'vitest';
import { formatCurrency, parseCurrency } from './currency';

describe('formatCurrency', () => {
  it('formats USD correctly', () => {
    expect(formatCurrency(1234.56, 'USD')).toBe('$1,234.56');
  });

  it('formats EUR correctly', () => {
    expect(formatCurrency(1000, 'EUR')).toBe('€1,000.00');
  });

  it('handles zero', () => {
    expect(formatCurrency(0, 'USD')).toBe('$0.00');
  });
});

// Type-level testing with ts-expect-error
it('rejects non-numeric amounts at compile time', () => {
  // @ts-expect-error — TypeScript should reject this
  formatCurrency('100', 'USD');
});

#Mocking with TypeScript

typescript
import { vi, type MockedFunction } from 'vitest';
import { UserService } from './UserService';
import { sendEmail } from '../email/sendEmail';

vi.mock('../email/sendEmail');

const mockSendEmail = sendEmail as MockedFunction<typeof sendEmail>;

describe('UserService.create', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    mockSendEmail.mockResolvedValue({ messageId: 'mock-id' });
  });

  it('sends a welcome email after creating user', async () => {
    const service = new UserService();
    await service.create({ name: 'Alice', email: 'alice@example.com' });

    expect(mockSendEmail).toHaveBeenCalledOnce();
    expect(mockSendEmail).toHaveBeenCalledWith({
      to: 'alice@example.com',
      subject: expect.stringContaining('Welcome'),
      body: expect.any(String),
    });
  });
});

Caption: Casting with sendEmail as MockedFunction<typeof sendEmail> gives the mock variable all Vitest mock assertion methods (toHaveBeenCalledOnce, toHaveBeenCalledWith) while keeping the original function's parameter and return types — no any required.

§ 13

#Common Mistakes & How to Fix Them

#Mistake 1: Using any Instead of unknown

typescript
// ❌ BAD — any disables all type checking
async function fetchData(url: string): Promise<any> {
  const res = await fetch(url);
  return res.json();
}

const data = await fetchData('/api/user');
data.nonExistentField.nested; // No error — TypeScript has given up

// ✅ GOOD — unknown forces you to narrow before use
async function fetchData(url: string): Promise<unknown> {
  const res = await fetch(url);
  return res.json();
}

const data = await fetchData('/api/user');
// data.name; // ❌ Compile error — must narrow first

if (isUser(data)) {
  console.log(data.name); // ✅ Safe
}

#Mistake 2: Non-Null Assertions Everywhere

typescript
// ❌ BAD — runtime crash waiting to happen
function getUserName(id: string): string {
  const user = userCache.get(id);
  return user!.name; // ! tells TypeScript to trust you — but what if it is null?
}

// ✅ GOOD — handle the null case explicitly
function getUserName(id: string): string {
  const user = userCache.get(id);
  if (!user) {
    throw new Error(`User ${id} not found in cache`);
  }
  return user.name;
}

// ✅ ALSO GOOD — provide a fallback
function getUserDisplayName(id: string): string {
  return userCache.get(id)?.name ?? 'Anonymous';
}

#Mistake 3: Type Assertions Instead of Type Guards

typescript
// ❌ BAD — casting without checking
const data = JSON.parse(rawString) as User;
data.name.toUpperCase(); // Runtime crash if data is not actually a User

// ✅ GOOD — validate before casting
const data = JSON.parse(rawString);

if (!isUser(data)) {
  throw new TypeError(`Invalid user data: ${rawString}`);
}

data.name.toUpperCase(); // Safe — TypeScript and runtime agree

#Mistake 4: Typing catch Variables as Error

typescript
// ❌ BAD — in TypeScript 4.0+, catch variables are unknown, not Error
try {
  await riskyOperation();
} catch (e: Error) { // ❌ Compile error in strict mode
  console.error(e.message);
}

// ✅ GOOD — always narrow catch variables
try {
  await riskyOperation();
} catch (e) {
  if (e instanceof Error) {
    console.error(e.message);
  } else {
    console.error('Unknown error:', String(e));
  }
}

// ✅ ALSO GOOD — a utility function
function toError(e: unknown): Error {
  return e instanceof Error ? e : new Error(String(e));
}

try {
  await riskyOperation();
} catch (e) {
  const error = toError(e);
  logger.error(error.message, { stack: error.stack });
}

Caption: JavaScript can throw anything — strings, numbers, plain objects — not just Error instances. TypeScript correctly types catch (e) as unknown to force you to handle this reality. Never annotate a catch variable as Error directly.

#Mistake 5: Forgetting readonly for Immutable Data

typescript
// ❌ BAD — arrays and objects can be mutated accidentally
interface Config {
  allowedOrigins: string[];
  features: { name: string; enabled: boolean }[];
}

function processConfig(config: Config) {
  config.allowedOrigins.push('evil.com'); // No compile error — but this is a bug
}

// ✅ GOOD — use readonly to signal and enforce immutability
interface Config {
  readonly allowedOrigins: readonly string[];
  readonly features: ReadonlyArray<{ readonly name: string; readonly enabled: boolean }>;
}

function processConfig(config: Config) {
  config.allowedOrigins.push('evil.com'); // ❌ Compile error: push does not exist on readonly array
}
§ 14

#References

[1] Greif, S., & Benitte, R. (2025). State of JavaScript 2025. Retrieved from https://stateofjs.com

[2] Laamiri, B. (2019). TypeScript's Type System is Turing Complete. Retrieved from https://github.com/microsoft/TypeScript/issues/14833

[3] TypeScript Team. (2024). TypeScript 5.0 Release Notes. Microsoft. Retrieved from https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-0.html

[4] TypeScript Team. (2024). TypeScript 5.2 using and await using. Microsoft. Retrieved from https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html

[5] TypeScript Team. (2025). The TypeScript Handbook. Microsoft. Retrieved from https://www.typescriptlang.org/docs/handbook/intro.html

[6] Hejlsberg, A. (2024). TypeScript Design Goals. GitHub. Retrieved from https://github.com/microsoft/TypeScript/wiki/TypeScript-Design-Goals

[7] Dodds, K. C. (2023). Type-Safe Error Handling in TypeScript. kentcdodds.com. Retrieved from https://kentcdodds.com/blog/get-a-catch-block-error-message-with-typescript

[8] Tan, T. (2025). tRPC Documentation. Retrieved from https://trpc.io/docs

[9] Colinhacks. (2025). Zod Documentation. Retrieved from https://zod.dev

[10] Prisma Team. (2025). Prisma with TypeScript. Retrieved from https://www.prisma.io/docs/orm/prisma-schema

[11] Vitest Team. (2025). Vitest Documentation. Retrieved from https://vitest.dev/guide/

[12] Vanderkam, D. (2021). Effective TypeScript: 62 Specific Ways to Improve Your TypeScript. O'Reilly Media. ISBN: 978-1492053743

Last updated: April 2026. All code examples use TypeScript 5.x and Node.js 22+.

Enjoyed this piece?

Be the first to comment

No comments yet. Start the conversation.