TypeScript adoption is nearly universal in 2026. The interesting question is no longer "should we use TypeScript?" but "are we using it well?" This guide covers the patterns that prevent entire categories of runtime bugs, not just type errors.
Strict mode: the non-negotiable baseline
Enable all strict checks in tsconfig.json:
{"compilerOptions": {"strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true}}
strict: trueenables strictNullChecks, noImplicitAny, and 7 others.noUncheckedIndexedAccessaddsundefinedto array index results — catchesarr[0].namecrashes when the array is empty.exactOptionalPropertyTypesprevents assigningundefinedto an optional property when the property simply shouldn't exist.
Discriminated unions over boolean flags
Boolean flags create exponentially growing state combinations. A component with 3 booleans has 8 possible states — most invalid. Model state as discriminated unions instead:
// Bad: 8 state combinations, most are invalid
type State = { isLoading: boolean; isError: boolean; data?: User };
// Good: exactly the valid states
type State = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: User } | { status: 'error'; error: string };
TypeScript narrows the type inside a switch on status, ensuring you can't accidentally access data in the error state.
The satisfies operator
satisfies validates that a value conforms to a type while preserving the literal type for further inference:
const config = { routes: ['/home', '/about'] } satisfies Config;
// config.routes is string[] (preserved), not Config['routes'] (widened)
Use it when you want type checking without losing the precision of literal inference.
Template literal types for safe string APIs
Template literal types let you constrain strings to a specific pattern:
type EventName = `on${Capitalize<string>}`; // 'onClick', 'onChange', etc.
type CSSProperty = `${string}-${string}`; // 'margin-top', 'padding-left', etc.
Combined with mapped types, you can build safe event emitters, CSS-in-JS APIs, and routing utilities that enforce naming conventions at compile time.
Branded types for domain modeling
TypeScript's structural typing means two types with the same shape are interchangeable. Branded types add a unique nominal brand to prevent accidental interchange:
type UserId = string & { readonly __brand: 'UserId' };
type ProductId = string & { readonly __brand: 'ProductId' };
function getUser(id: UserId) { ... }
getUser('abc' as UserId); // OK
getUser(productId); // Type error!
This catches the class of bug where you pass a product ID where a user ID is expected — both are strings structurally, but semantically different.
const type parameters
The const modifier on type parameters infers literal types instead of widened types:
function createRoute<const T extends string>(path: T) { return path; }
const route = createRoute('/users'); // type: '/users', not string
Avoid type assertions — use type guards
Type assertions (as SomeType) bypass the type checker entirely. Replace them with type guard functions that actually verify the type at runtime:
function isUser(value: unknown): value is User { return typeof value === 'object' && value !== null && 'id' in value && 'name' in value; }
A well-written type guard both narrows the TypeScript type and validates at runtime — essential at API boundaries where unknown data arrives.
Infer return types, don't annotate them
Explicit return type annotations on functions are redundant when TypeScript can infer them. They also create a maintenance burden — you annotate the return type, then the implementation changes, and the annotation becomes a lie that TypeScript enforces.
Exception: public API functions in library code, where the return type is part of the contract and should be enforced.