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.tsfiles: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
- Wrapping JavaScript libraries without existing types
- Declaring ambient module types (e.g., CSS modules, environment variables)
- 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
- TypeScript Handbook — Official documentation
- TypeScript 5.5 Release Notes — Inferred type predicates, isolated declarations, performance
- TypeScript 5.0 Release Notes — Const type parameters, bundler module resolution, decorators
- TypeScript Project References — Official guide to incremental builds
- TypeScript Repository — Source code and issue tracker
- TypeScript Deep Dive — Community reference guide
Comments