Skip to main content

TypeScript Best Practices 2026: Modern TypeScript Development

Created: March 7, 2026 Larry Qu 19 min read
Table of Contents

Introduction

TypeScript has evolved rapidly through 5.x releases, adding powerful type-level features that change how professional TypeScript is written. This guide covers modern TypeScript best practices for 2026 — from the satisfies operator and template literal types to module resolution strategies and build performance optimization.

Type Inference

Let TypeScript Infer

TypeScript’s type inference is sophisticated. Let it work for you, but know when to step in.

// Let inference work
const name = "John";        // type: "John"
const count = 10;           // type: number
const items = [] as const;  // type: readonly []

// Explicit when needed
const config: Config = loadConfig();

// Arrays need annotation when empty
const userList: string[] = [];
// Function return types can usually be inferred
function createUser(name: string, email: string) {
  return { id: crypto.randomUUID(), name, email };
}
// Return type is { id: string; name: string; email: string; }

// Explicit return types for public API surfaces
export function fetchUser(id: string): Promise<User> {
  return db.users.findById(id);
}

Type Annotations for Boundaries

Annotate function signatures and export boundaries. Let inference handle internal implementation details.

interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
}

// Parameters need annotations
function greet(name: string): string {
  return `Hello, ${name}`;
}

// Destructured parameters with inline types
function updateUser(
  id: string,
  { name, email }: Pick<User, 'name' | 'email'>
): Promise<User> {
  return db.users.update(id, { name, email });
}

The satisfies Operator (TypeScript 4.9+)

The satisfies operator validates that an expression matches a type without changing the inferred type. Unlike type assertions (as), it preserves the narrowest inferred type while checking compatibility.

Preserving Literal Types

type Color = "red" | "green" | "blue";
type Theme = Record<Color, string>;

const palette = {
  red: "#ff0000",
  green: "#00ff00",
  blue: "#0000ff",
} satisfies Theme;

// palette.red is typed as "#ff0000", not string
// Accessing palette.orange would be a compile error

Object Structure Validation

type Routes = Record<string, { path: string; label: string; auth: boolean }>;

const appRoutes = {
  home: { path: "/", label: "Home", auth: false },
  dashboard: { path: "/dashboard", label: "Dashboard", auth: true },
  admin: { path: "/admin", label: "Admin Panel", auth: true },
} satisfies Routes;

// appRoutes.dashboard.label is inferred as "Dashboard", not string
// Missing required fields or extra keys are caught

Array and Tuple Validation

type PermissionMap = Record<string, boolean>;

const permissions = {
  admin: true,
  editor: true,
  viewer: false,
} satisfies PermissionMap;

// permissions.admin is typed as `true`, not boolean
// permissions.viewer is typed as `false`

Difference from as Assertions

Feature satisfies as (Type Assertion)
Type checking Validates compatibility Silences errors
Inferred type Preserves narrow type Widens to target type
Safety Safe — catches mismatches Unsafe — can hide bugs
Use case Validation without widening Escape hatch for special cases
// Bad: 'as' widens and can hide mismatches
const badConfig = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
  retries: "three", // No error!
} as Config;

// Good: 'satisfies' catches the error while preserving narrow types
const goodConfig = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
  retries: "three", // Error: Type 'string' is not assignable to type 'number'
} satisfies Config;

Template Literal Types

Template literal types, introduced in TypeScript 4.1, let you construct string literal types at the type level using template literal syntax.

Basic Syntax

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

type CSSUnit = "px" | "em" | "rem" | "%" | "vh" | "vw";
type CSSValue = `${number}${CSSUnit}`;
// Result: "100px" | "50vh" | "2.5rem" | ...

const width: CSSValue = "100px";   // OK
const height: CSSValue = "50vh";   // OK
const invalid: CSSValue = "10";    // Error: missing unit

Building API Event Systems

type RegularEvent = "read" | "create" | "update";
type AdminEvent = "delete";
type Entity = "message" | "post" | "comment";

type RegularEvents = `${RegularEvent}-${Entity}`;
type AdminEvents = `${RegularEvent | AdminEvent}-${Entity}`;

type Role = "admin" | "user";

type RoleEvents<R extends Role> =
  R extends "admin"
    ? `admin-${AdminEvents}`
    : `user-${RegularEvents}`;

function fireEvent<R extends Role>(
  role: R,
  event: RoleEvents<R>
): void {
  console.log(`${role} fired ${event}`);
}

fireEvent("admin", "admin-delete-message"); // OK
fireEvent("user", "user-create-comment");   // OK
fireEvent("user", "user-delete-post");     // Error

String Manipulation Type Utilities

TypeScript provides built-in string manipulation types that work with template literals:

type Greeting = "helloWorld";
type UpperGreeting = Uppercase<Greeting>;   // "HELLOWORLD"
type LowerGreeting = Lowercase<Greeting>;   // "helloworld"
type Capitalized = Capitalize<Greeting>;    // "HelloWorld"
type Uncapitalized = Uncapitalize<Greeting>; // "helloWorld"

// Practical: Converting API response keys
type ApiResponse = {
  user_name: string;
  user_email: string;
  created_at: string;
};

type CamelCaseKey<S extends string> =
  S extends `${infer T}_${infer U}`
    ? `${T}${Capitalize<CamelCaseKey<U>>}`
    : S;

type CamelCaseResponse = {
  [K in keyof ApiResponse as CamelCaseKey<K>]: ApiResponse[K];
};
// { userName: string; userEmail: string; createdAt: string; }

Parsing with infer

type ExtractRouteParam<Path extends string> =
  Path extends `/api/${infer Resource}/${infer Id}`
    ? { resource: Resource; id: Id }
    : never;

type UserRoute = ExtractRouteParam<"/api/users/123">;
// { resource: "users"; id: "123" }

Branded Types and Nominal Typing

TypeScript uses structural typing — types with the same shape are interchangeable. Branded types add a phantom property to create nominal-like discrimination.

The Problem with Structural Typing

type UserId = string;
type PostId = string;

function deleteUser(id: UserId): void {}
function deletePost(id: PostId): void {}

const userId: UserId = "user-123";
const postId: PostId = "post-456";

deleteUser(postId); // No error — but should be wrong!
deletePost(userId); // No error — also wrong!

Branded Types Solution

type Brand<T, B> = T & { __brand: B };

type UserId = Brand<string, "UserId">;
type PostId = Brand<string, "PostId">;

function createUserId(raw: string): UserId {
  return raw as UserId;
}

function createPostId(raw: string): PostId {
  return raw as PostId;
}

function deleteUser(id: UserId): void {}

const userId = createUserId("user-123");
const postId = createPostId("post-456");

deleteUser(userId); // OK
deleteUser(postId); // Error! Type 'PostId' not assignable to 'UserId'

Flavoring (Weak Branding)

For cases where you want a softer check that doesn’t survive assignment:

type Flavor<T, F> = T & { __flavor?: F };

type Email = Flavor<string, "Email">;
type PhoneNumber = Flavor<string, "Phone">;

function sendEmail(to: Email, message: string): void {}

const adminEmail = "[email protected]" as Email;
sendEmail(adminEmail, "Hello"); // OK

// Regular strings are still assignable (unlike strict branding)
const regular = "[email protected]";
sendEmail(regular, "Hi"); // Also OK with flavoring

Branded Types in Entity IDs

type Brand<T, B> = T & { __brand: B };

type OrderId = Brand<string, "OrderId">;
type CustomerId = Brand<string, "CustomerId">;
type ProductId = Brand<string, "ProductId">;

class OrderService {
  getOrder(orderId: OrderId): Order { /* ... */ }
  getCustomerOrders(customerId: CustomerId): Order[] { /* ... */ }
  addProductToOrder(orderId: OrderId, productId: ProductId): void { /* ... */ }
}

// The compiler prevents mixing up IDs at the call site
const orderId = "ORD-123" as OrderId;
const customerId = "CUST-456" as CustomerId;

orderService.getCustomerOrders(orderId); // Error!
orderService.getOrder(customerId);        // Error!

Discriminated Unions with Exhaustive Checking

Discriminated unions are the backbone of safe state management in TypeScript.

Basic Discriminated Union

type ApiState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function renderUser(state: ApiState<User>) {
  switch (state.status) {
    case "idle":
      return "Initializing...";
    case "loading":
      return "Loading...";
    case "success":
      return `Hello, ${state.data.name}`;
    case "error":
      return `Error: ${state.error.message}`;
  }
}

Exhaustive Switch with never

function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${value}`);
}

function renderState(state: ApiState<User>): string {
  switch (state.status) {
    case "idle":
      return "";
    case "loading":
      return "Loading...";
    case "success":
      return state.data.name;
    case "error":
      return state.error.message;
    default:
      return assertNever(state); // Compile error if a case is missing
  }
}

Using satisfies with Discriminated Unions

type Action =
  | { type: "increment"; payload: number }
  | { type: "decrement"; payload: number }
  | { type: "reset" }
  | { type: "set"; payload: number };

const reducer = (state: number, action: Action): number => {
  switch (action.type) {
    case "increment":
      return state + action.payload;
    case "decrement":
      return state - action.payload;
    case "reset":
      return 0;
    case "set":
      return action.payload;
    default:
      return assertNever(action);
  }
};

Pattern Matching with Return Types

type Event =
  | { kind: "page_view"; url: string; referrer?: string }
  | { kind: "click"; target: string; x: number; y: number }
  | { kind: "scroll"; depth: number; direction: "up" | "down" };

function handleEvent(event: Event): string {
  return event satisfies Event; // Ensures all variants are covered
}

function getEventCategory(event: Event): string {
  return event.kind satisfies Event["kind"];
  // TypeScript knows event.kind is "page_view" | "click" | "scroll"
}

Const Type Parameters (TypeScript 5.0+)

Const type parameters let you infer literal types in generic functions without requiring as const at every call site.

The Problem

function createConfig<T extends Record<string, unknown>>(config: T): T {
  return config;
}

const config = createConfig({
  apiVersion: "v2",     // Inferred as string, not "v2"
  timeout: 5000,        // Inferred as number, not 5000
  retries: 3,           // Inferred as number, not 3
});
// config.timeout is number — loses literal precision

Solution with const Type Parameter

function createConfig<const T extends Record<string, unknown>>(config: T): T {
  return config;
}

const config = createConfig({
  apiVersion: "v2",     // Inferred as "v2"
  timeout: 5000,        // Inferred as 5000
  retries: 3,           // Inferred as 3
});
// config.timeout is 5000 (literal type preserved)

Practical: HTTP Route Builder

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";

function defineRoute<
  const M extends HttpMethod,
  const P extends string
>(method: M, path: P, handler: () => void) {
  return { method, path, handler } as const;
}

const getUser = defineRoute("GET", "/users/:id", () => {});
// method: "GET" (not string)
// path: "/users/:id" (not string)

const createUser = defineRoute("POST", "/users", () => {});
// method: "POST" (not string)

Practical: Typed Event Emitter

type EventMap = {
  userCreated: { id: string; name: string };
  userDeleted: { id: string };
  error: { message: string; code: number };
};

function createEmitter<const T extends Record<string, unknown>>() {
  return {
    on<K extends keyof T>(event: K, handler: (data: T[K]) => void) {
      console.log(`Registered handler for ${String(event)}`);
    },
    emit<K extends keyof T>(event: K, data: T[K]) {
      console.log(`Emitting ${String(event)}`);
    },
  };
}

const emitter = createEmitter<EventMap>();
emitter.on("userCreated", (user) => {
  // user is { id: string; name: string }
  console.log(user.name);
});
emitter.emit("error", { message: "Not found", code: 404 });

Improved Inference in TypeScript 5.x

Inferred Type Predicates (TS 5.5)

TypeScript 5.5 can infer type predicates from function bodies automatically.

// Before TS 5.5 — needed explicit type predicate
function isString(value: unknown): value is string {
  return typeof value === "string";
}

// TS 5.5+ — inferred automatically
function isString(value: unknown) {
  return typeof value === "string";
  // Inferred as: (value: unknown) => value is string
}
// Practical: filtering nulls from arrays
const users = [null, { id: "1" }, null, { id: "2" }];
const validUsers = users.filter((user): user is { id: string } => user !== null);
// validUsers: { id: string }[] — no manual cast needed

// TS 5.5+ can even infer this automatically
const filtered = [1, 2, null, 4].filter(x => x !== null);
// filtered: number[] (was (number | null)[] in TS 5.4)

Control Flow Narrowing for Indexed Accesses (TS 5.5)

function processValue(obj: Record<string, unknown>, key: string) {
  if (typeof obj[key] === "string") {
    // TS 5.5+ correctly narrows obj[key] to string here
    obj[key].toUpperCase(); // OK
  }
}

The NoInfer Utility Type (TS 5.4)

// Prevents one type parameter from contributing to inference
function createEntity<T extends { id: string }, const K extends keyof T>(
  data: NoInfer<Omit<T, K>>,
  key: K,
): T {
  return { ...data, [key]: crypto.randomUUID() } as T;
}

type User = { id: string; name: string; email: string };
const user = createEntity<User>("name", { name: "Alice", email: "[email protected]" });
// T is inferred from the second argument, not the first

Isolated Declarations (TS 5.5)

// With --isolatedDeclarations enabled, exports must have explicit types
export function add(x: number, y: number): number {
  return x + y;
}

// Error without return type annotation:
export function multiply(x: number, y: number) {
  // ~~~ Function must have an explicit return type annotation
  return x * y;
}

Module Resolution Strategies

TypeScript offers several module resolution strategies, each suited for different environments.

Resolution Strategies Comparison

Strategy Version Use Case Key Behavior
node Legacy Older Node.js projects Classic Node resolution, CJS-first
node16 TS 4.7+ Node.js ESM projects Respects package.json exports, ESM-first
nodenext TS 4.7+ Cutting-edge Node.js Evolves with Node.js releases
bundler TS 5.0+ Bundlers (webpack, Vite, esbuild) More permissive, no extension resolution
classic Legacy Pre-TS 1.6 projects Deprecated

bundler Module Resolution (TS 5.0+)

{
  "compilerOptions": {
    "module": "esnext",
    "moduleResolution": "bundler",
    "target": "esnext"
  }
}

The bundler strategy is designed for projects that use a bundler in their build pipeline. It allows:

  • Imports without file extensions: import { User } from "./models"
  • Imports to index.ts files: import { config } from "./utils"
  • More permissive resolution matching bundler behavior

nodenext for Native ESM

{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "esnext",
    "outDir": "./dist",
    "rootDir": "./src"
  }
}
// Must include extensions in ESM
import { User } from "./models/user.js";
import { createServer } from "http";

// Respects package.json "exports" field
import { parse } from "csv-parse/sync";

Package.json Exports Maps

{
  "name": "@calmops/utils",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    },
    "./hooks": {
      "import": "./dist/hooks/index.js",
      "types": "./dist/hooks/index.d.ts"
    },
    "./internal/*": null
  }
}
// Consumers use clean import paths
import { formatDate } from "@calmops/utils";
import { useDebounce } from "@calmops/utils/hooks";

// Private/internal paths are blocked
import { internalHelper } from "@calmops/utils/internal/helper"; // Error!

Path Aliases vs Project References

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@shared/*": ["./packages/shared/src/*"],
      "@server/*": ["./apps/server/src/*"]
    }
  }
}

Path aliases improve readability but don’t improve performance — the entire workspace still resolves as a single TypeScript project. For true incremental builds, use project references.

Declaration Files (.d.ts)

Declaration files describe the shape of JavaScript libraries to TypeScript.

When to Write .d.ts Manually

  1. Wrapping JavaScript libraries without existing types
  2. Declaring ambient module types (e.g., CSS modules, environment variables)
  3. Publishing type-only packages

Ambient Declarations

// env.d.ts
declare namespace NodeJS {
  interface ProcessEnv {
    NODE_ENV: "development" | "production" | "test";
    API_KEY: string;
    DATABASE_URL: string;
    PORT?: string;
  }
}

// Usage in .ts files
const dbUrl: string = process.env.DATABASE_URL;

Module Augmentation

// augmentations.d.ts
import "express";

declare module "express" {
  interface Request {
    user?: {
      id: string;
      role: "admin" | "user";
    };
    requestId: string;
  }
}

CSS Module Declarations

// css-modules.d.ts
declare module "*.module.css" {
  const classes: { readonly [key: string]: string };
  export default classes;
}

declare module "*.module.scss" {
  const classes: { readonly [key: string]: string };
  export default classes;
}

Publishing Type Packages

// Package: @calmops/date-utils
// File: index.d.ts

export interface DateOptions {
  format: "iso" | "relative" | "timestamp";
  locale?: string;
  timezone?: string;
}

export function formatDate(date: Date, options?: DateOptions): string;
export function parseDate(input: string): Date;
export function isWeekend(date: Date): boolean;

Async Patterns

Typed Promise Results

type AsyncResult<T, E = Error> =
  | { success: true; data: T }
  | { success: false; error: E };

async function fetchUser(id: string): Promise<AsyncResult<User>> {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
      return {
        success: false,
        error: new Error(`HTTP ${response.status}: ${response.statusText}`),
      };
    }
    return { success: true, data: await response.json() };
  } catch (error) {
    return {
      success: false,
      error: error instanceof Error ? error : new Error("Unknown error"),
    };
  }
}

Typed Async Generators

interface Page<T> {
  items: T[];
  nextCursor?: string;
  hasMore: boolean;
}

async function* paginate<T>(
  fetchPage: (cursor?: string) => Promise<Page<T>>
): AsyncGenerator<T[], void, undefined> {
  let cursor: string | undefined;
  let hasMore = true;

  while (hasMore) {
    const page = await fetchPage(cursor);
    yield page.items;
    cursor = page.nextCursor;
    hasMore = page.hasMore;
  }
}

// Usage
async function processAllUsers() {
  const generator = paginate<User>((cursor) =>
    fetch(`/api/users?cursor=${cursor ?? ""}`).then(r => r.json())
  );

  for await (const batch of generator) {
    for (const user of batch) {
      console.log(user.name);
    }
  }
}

Promise.all with Typed Tuples

async function fetchDashboardData(userId: string) {
  const [user, posts, notifications] = await Promise.all([
    fetchUser(userId),
    fetchPosts(userId),
    fetchNotifications(userId),
  ] as const);

  // user: User (not User | Posts | Notifications)
  // posts: Post[]
  // notifications: Notification[]
  return { user, posts, notifications };
}

Error Handling with Typed Errors

class ValidationError extends Error {
  constructor(
    message: string,
    public readonly field: string,
    public readonly code: string
  ) {
    super(message);
    this.name = "ValidationError";
  }
}

class NetworkError extends Error {
  constructor(
    message: string,
    public readonly statusCode: number,
    public readonly retryable: boolean
  ) {
    super(message);
    this.name = "NetworkError";
  }
}

type AppError = ValidationError | NetworkError;

function handleError(error: AppError): string {
  switch (error.constructor) {
    case ValidationError:
      return `Validation failed on ${error.field}: ${error.message}`;
    case NetworkError:
      return `Network error (${error.statusCode}): ${error.message}`;
    default:
      return assertNever(error);
  }
}

Performance Optimization

Project References and Incremental Builds

Project references split a large TypeScript codebase into smaller, independently compilable projects, enabling incremental builds.

// tsconfig.base.json
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    "incremental": true,
    "skipLibCheck": true,
    "outDir": "./dist",
    "rootDir": "./src"
  }
}
// packages/shared/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "references": []
}
// apps/server/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "references": [
    { "path": "../../packages/shared" }
  ]
}
# Build all projects in dependency order
tsc --build

# Build only changed projects
tsc --build --incremental

# Watch mode
tsc --build --watch

# Clean build artifacts
tsc --build --clean

Key Compiler Options for Performance

Option Impact Trade-off
skipLibCheck: true Skips checking .d.ts files in node_modules No type checking for library declarations
incremental: true Caches compilation info to .tsbuildinfo Adds small disk overhead
composite: true Enables project references Requires declaration emit
isolatedModules: true Forces per-file transpilation compatibility Restricts some re-export patterns
isolatedDeclarations: true Enables parallel declaration emit (TS 5.5+) Requires explicit return types on exports

Optimizing tsconfig for Different Environments

// tsconfig.json (editor experience — broad)
{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "moduleResolution": "bundler",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}
// tsconfig.build.json (build — narrow + fast)
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./dist",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "skipLibCheck": true,
    "incremental": true
  },
  "include": ["src"],
  "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"]
}

Using typeRoots to Limit Type Lookups

{
  "compilerOptions": {
    "typeRoots": [
      "./node_modules/@types",
      "./src/types"
    ]
  }
}

Limiting typeRoots reduces the number of declaration files TypeScript loads, improving compilation speed.

Monorepo Best Practices

# .gitignore — add .tsbuildinfo
.tsbuildinfo
packages/*/dist/
apps/*/dist/

For large monorepos, consider using tRPC and Zod with TypeScript for end-to-end type safety — see our guide on type-safe full-stack development with tRPC and Zod.

Strict Mode Configuration

Essential Compiler Options

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "exactOptionalPropertyTypes": true,
    "noUncheckedIndexedAccess": true,
    "forceConsistentCasingInFileNames": true
  }
}

What Each Option Does

Option Behavior
strict Enables all strict type-checking options
strictNullChecks null and undefined are not assignable to other types
strictFunctionTypes Stronger function type variance checks
exactOptionalPropertyTypes Optional properties can’t have undefined assigned explicitly
noUncheckedIndexedAccess Indexed access adds undefined to the type
forceConsistentCasingInFileNames Ensures consistent file name casing in imports
// With noUncheckedIndexedAccess
const scores: Record<string, number> = {};
const mathScore = scores["math"];
// mathScore: number | undefined (not just number)

// With exactOptionalPropertyTypes
interface Config {
  timeout?: number;
}
const config: Config = { timeout: undefined };
// Error with exactOptionalPropertyTypes

Custom Utility Types

Essential Custom Utilities

// Nullable
type Nullable<T> = T | null;

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

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

// NonFunctionKeys — extract property keys that are not functions
type NonFunctionKeys<T> = {
  [K in keyof T]: T[K] extends Function ? never : K;
}[keyof T];

// PickByType — pick properties matching a value type
type PickByType<T, V> = {
  [K in keyof T as T[K] extends V ? K : never]: T[K];
};

// RequireAtLeastOne — at least one property must be present
type RequireAtLeastOne<T, Keys extends keyof T = keyof T> =
  Keys extends keyof T
    ? Omit<T, Keys> & Required<Pick<T, Keys>>
    : never;

Practical Usage

interface UserConfig {
  name: string;
  email: string;
  age?: number;
  address?: {
    street: string;
    city: string;
    zip?: string;
  };
}

// Deep partial for partial updates
type UserUpdate = DeepPartial<UserConfig>;
// Both shallow and nested properties become optional

// Pick only string properties from Config
type StringConfig = PickByType<UserConfig, string>;
// { name: string; email: string; }

Generics Patterns

Generic Functions and Constraints

function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
  return arr.map(fn);
}

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

Generic Constraints with Conditional Types

type ExtractPromise<T> = T extends Promise<infer U> ? U : T;

type UserPromise = Promise<User>;
type ResolvedUser = ExtractPromise<UserPromise>; // User

// Function that unwraps promises
function unwrap<T>(value: T): ExtractPromise<T> {
  if (value instanceof Promise) {
    return value.then(v => v) as ExtractPromise<T>;
  }
  return value as ExtractPromise<T>;
}

Type-safe Builder Pattern

class QueryBuilder<T extends Record<string, unknown>> {
  private conditions: string[] = [];
  private limitCount?: number;

  where<K extends keyof T>(field: K, value: T[K]): this {
    this.conditions.push(`${String(field)} = ${value}`);
    return this;
  }

  limit(count: number): this {
    if (count < 1) throw new Error("Limit must be >= 1");
    this.limitCount = count;
    return this;
  }

  build(): string {
    let query = `SELECT * FROM users`;
    if (this.conditions.length > 0) {
      query += ` WHERE ${this.conditions.join(" AND ")}`;
    }
    if (this.limitCount !== undefined) {
      query += ` LIMIT ${this.limitCount}`;
    }
    return query;
  }
}

// Usage
const query = new QueryBuilder<User>()
  .where("role", "admin")
  .where("status", "active")
  .limit(10)
  .build();

Type Guards

Custom Type Guards

function isString(value: unknown): value is string {
  return typeof value === "string";
}

function isUser(obj: unknown): obj is User {
  return (
    typeof obj === "object" &&
    obj !== null &&
    "id" in obj &&
    typeof (obj as Record<string, unknown>).id === "string" &&
    "name" in obj
  );
}

function isNonNullable<T>(value: T): value is NonNullable<T> {
  return value !== null && value !== undefined;
}

// Usage
const data: unknown = JSON.parse(payload);
if (isUser(data)) {
  console.log(data.name); // Narrowed to User
}

Array Type Guards with Filter

function isPresent<T>(value: T | null | undefined): value is T {
  return value !== null && value !== undefined;
}

const results: (User | null)[] = [user1, null, user2, null];
const validUsers: User[] = results.filter(isPresent);
// validUsers is User[] — correct type

Stream Processing with Typed Async

interface StreamEvent<T> {
  type: "data";
  payload: T;
}

interface StreamError {
  type: "error";
  error: Error;
}

interface StreamComplete {
  type: "complete";
}

type StreamChannel<T> = StreamEvent<T> | StreamError | StreamComplete;

async function processStream<T>(
  source: AsyncIterable<StreamChannel<T>>,
  handler: (item: T) => Promise<void>
): Promise<void> {
  for await (const channel of source) {
    switch (channel.type) {
      case "data":
        await handler(channel.payload);
        break;
      case "error":
        console.error("Stream error:", channel.error);
        throw channel.error;
      case "complete":
        console.log("Stream complete");
        return;
      default:
        assertNever(channel);
    }
  }
}

Conclusion

TypeScript in 2026 offers an incredibly rich type system. The satisfies operator preserves narrow types while validating structure. Template literal types and branded types bring string parsing and nominal typing to the type level. Const type parameters and improved inference reduce boilerplate. And project references with incremental builds keep large codebases fast.

For related reading, check out our guides on advanced TypeScript patterns, building type-safe APIs with TypeScript and Zod, and error handling in JavaScript.

Resources

Comments

👍 Was this article helpful?