7 Lesser-Known TypeScript Features: satisfies, Branded Types, and More
Seven lesser-known TypeScript features that improve production code: satisfies, noUncheckedIndexedAccess, branded types, discriminated unions, and more.
Turning on strict in tsconfig.json feels like the end of the type-safety conversation. It is not. Indexed access still returns a type that hides undefined, a UserID still slots into a function that expects an OrderID, and a member added to a union still slips past every switch you already wrote.
The highest-value change is noUncheckedIndexedAccess, a compiler flag that strict does not turn on. Add it first, then reach for the type-level tools that cover the rest: satisfies, branded types, discriminated unions with exhaustiveness checking, type predicates, template literal types, and infer. All seven are compile-time constructs, so none of them cost anything at runtime.
The Gaps Strict Mode Leaves Open
Each of these problems reaches production through code that type-checks cleanly.
Type Safety Gaps: Using any everywhere defeats TypeScript’s purpose, allowing type errors to slip through to runtime.
Array Access Without Guards: The expression array[5] can return undefined, but TypeScript’s default configuration doesn’t warn you - even with strict mode enabled.
Structural Type Confusion: TypeScript uses structural typing, meaning UserID and OrderID (both numbers) are interchangeable, leading to data corruption when IDs get mixed up.
Incomplete Union Handling: Adding a new case to a state type doesn’t break existing switch statements, causing unhandled cases in production.
Weak Validation Boundaries: External data from APIs needs runtime validation, but the connection between validation logic and type narrowing is often unclear.
Configuration Type Loss: Using type assertions on configuration objects loses valuable type information that could catch errors.
Nested Type Extraction: Complex generic types require manual type extraction, leading to duplication and drift.
The Seven Features
1. The satisfies Operator with Const Assertions
The satisfies operator (TypeScript 4.9+) combines type validation with precise literal type inference - giving you both compile-time checking and specific types.
The Problem: Configuration objects need validation against a type schema, but using as type assertions loses literal type information.
// Without satisfies - loses type information
const routes = {
home: { path: '/', methods: ['GET', 'POST'] },
api: { path: '/api', methods: ['POST'] }
} as const;
// routes.home.path is '/' (good), but no validation
The Solution: Use as const satisfies for immutability plus validation.
type Route = {
path: string;
methods: readonly ('GET' | 'POST' | 'PUT' | 'DELETE')[];
};
type Routes = Record<string, Route>;
const routes = {
home: { path: '/', methods: ['GET', 'POST'] },
api: { path: '/api', methods: ['POST'] },
// TypeScript error if you uncomment:
// invalid: { path: '/bad', methods: ['INVALID'] }
} as const satisfies Routes;
// Now: type-checked AND precise literal types
routes.home.path; // type: '/' (literal, not string)
routes.api.methods; // type: readonly ['POST']
When to use: API configurations, theme definitions, routing tables, feature flags.
When NOT to use: Dynamic runtime data, values that change frequently.
What it catches: A typo in an HTTP method or a missing path fails the build instead of a request. The error also points at the offending key inside the object literal rather than at the type alias, which is what makes it useful in a large config file.
2. noUncheckedIndexedAccess - The Missing Strict Flag
Here’s a critical configuration detail: the noUncheckedIndexedAccess compiler option is not included in strict mode, yet it prevents an entire class of “Cannot read property of undefined” errors.
The Problem: Array and object indexed access can return undefined, but TypeScript’s default behavior doesn’t reflect this reality.
// tsconfig.json - default strict mode
{
"compilerOptions": {
"strict": true
}
}
// This code looks safe but isn't
const users = ['Alice', 'Bob'];
const user = users[5]; // type: string (WRONG - it's actually undefined!)
user.toUpperCase(); // Runtime error: Cannot read property 'toUpperCase' of undefined
The Solution: Enable noUncheckedIndexedAccess explicitly.
// tsconfig.json - production-ready strict mode
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true // Add this!
}
}
// Now TypeScript reflects reality
const users = ['Alice', 'Bob'];
const user = users[5]; // type: string | undefined (CORRECT)
// TypeScript forces you to handle undefined
if (user) {
user.toUpperCase(); // Safe
}
// Or use optional chaining
const upperName = users[5]?.toUpperCase();
Migration cost: Turning this on in an existing codebase produces one error per unguarded index, so the count scales with how much array work the code does. Most are mechanical fixes: an ?., a length check, or a destructure with a default. Do them in one pass rather than file by file, or the flag gets switched back off.
Why underutilized: This option isn’t part of strict mode, so many developers don’t know it exists.
3. Branded Types for Nominal Type Safety
TypeScript uses structural typing, meaning two types with identical structure are interchangeable. While this is powerful, it can lead to subtle bugs when you want nominal typing behavior.
The Problem: Structurally identical types (both numbers) can be confused.
// The problem
type UserID = number;
type OrderID = number;
function getUser(id: UserID) { /* ... */ }
function getOrder(id: OrderID) { /* ... */ }
const userId: UserID = 123;
const orderId: OrderID = 456;
getUser(orderId); // TypeScript allows this (BAD!)
In a multi-tenant system this failure mode does not announce itself. The query runs successfully against the wrong tenant, so the mistake surfaces as leaked data rather than as an exception in the logs.
The Solution: Branded types create nominal-like behavior at the type level.
// Generic brand utility
type Brand<K, T> = K & { readonly __brand: T };
type UserID = Brand<number, 'UserID'>;
type OrderID = Brand<number, 'OrderID'>;
// Constructor functions for creating branded values
const UserID = (id: number): UserID => id as UserID;
const OrderID = (id: number): OrderID => id as OrderID;
const userId = UserID(123);
const orderId = OrderID(456);
getUser(orderId); // BAD: TypeScript error: Type 'OrderID' is not assignable to type 'UserID'
Production use cases:
- Database IDs (preventing ID confusion in multi-tenant systems)
- Currency values (USD vs EUR)
- Email addresses vs general strings
- Validated vs unvalidated user input
Performance: Zero runtime overhead - the brand exists only at the type level and is erased during compilation.
Advanced pattern: Combine branded types with validation functions.
type Email = Brand<string, 'Email'>;
const Email = (value: string): Email => {
if (!value.includes('@') || !value.includes('.')) {
throw new Error('Invalid email format');
}
return value as Email;
};
// Now email variables are guaranteed to be validated
function sendEmail(to: Email) {
// No need to validate again - type system guarantees it
}
4. Discriminated Unions with Exhaustiveness Checking
State machines and API responses benefit greatly from discriminated unions combined with exhaustiveness checking through the never type.
The Problem: Switch statements that don’t handle all cases lead to runtime failures.
type Result<T> =
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
| { status: 'loading' };
// Without exhaustiveness checking
function handleResult<T>(result: Result<T>) {
switch (result.status) {
case 'success':
return result.data;
case 'error':
throw result.error;
// Forgot 'loading' case - no error!
}
// Returns undefined for loading state - bug!
}
The Solution: Use the never type to enforce exhaustiveness.
function handleResult<T>(result: Result<T>) {
switch (result.status) {
case 'success':
return result.data;
case 'error':
throw result.error;
case 'loading':
return null;
default:
// This forces TypeScript to check all cases
const exhaustive: never = result;
throw new Error(`Unhandled case: ${exhaustive}`);
}
}
// Adding a new status breaks compilation
type Result<T> =
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
| { status: 'loading' }
| { status: 'cancelled' }; // TypeScript now errors in handleResult
Why powerful: When you add a new union member, TypeScript immediately highlights every location that needs updating. Adding a 'retry' state to an API client stops being an audit of every call site and becomes a compiler-generated task list.
5. Type Predicates vs Assertion Functions
TypeScript offers two patterns for type narrowing: type predicates and assertion functions. Understanding when to use each is crucial for clean, type-safe validation logic.
Type Predicate: Returns a boolean, used in conditional checks.
function isString(value: unknown): value is string {
return typeof value === 'string';
}
// Use in conditionals
const data: unknown = getSomeData();
if (isString(data)) {
data.toUpperCase(); // data is string here
}
Assertion Function: Throws or returns void, narrows the type for the rest of the scope.
function assertString(value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new Error('Not a string');
}
}
// Use for validation
const data: unknown = getSomeData();
assertString(data); // throws if not string
data.toUpperCase(); // data is string for rest of scope
Advanced example: Custom domain object validation.
type User = {
id: number;
email: string;
name: string;
};
function assertUser(obj: unknown): asserts obj is User {
if (
typeof obj !== 'object' ||
obj === null ||
!('id' in obj) ||
!('email' in obj) ||
!('name' in obj) ||
typeof obj.id !== 'number' ||
typeof obj.email !== 'string' ||
typeof obj.name !== 'string'
) {
throw new Error('Invalid user object');
}
}
// API response validation
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data: unknown = await response.json();
assertUser(data); // validates structure
return data; // TypeScript knows it's User
}
When to use predicates: Optional checks, filter operations, conditional logic.
When to use assertions: Mandatory validation, parse functions, guard clauses at system boundaries.
Critical gotcha: Assertion functions should throw on failure, not return false. Returning false doesn’t narrow the type.
6. Template Literal Types for String Patterns
Template literal types (TypeScript 4.1+) enable type-safe string manipulation with zero runtime cost.
The Problem: String patterns and conventions need compile-time validation.
CSS Unit Types:
type CSSUnit = 'px' | 'em' | 'rem' | '%';
type CSSValue<T extends string> = `${number}${T}`;
type Padding = CSSValue<CSSUnit>;
const padding: Padding = '10px'; // const invalid: Padding = '10abc'; // BAD: Error
Event Handler Naming:
type EventName = 'click' | 'focus' | 'blur' | 'hover';
type EventHandler<T extends EventName> = `on${Capitalize<T>}`;
type ClickHandler = EventHandler<'click'>; // 'onClick'
type FocusHandler = EventHandler<'focus'>; // 'onFocus'
type Handlers = {
[K in EventName as EventHandler<K>]: (event: Event) => void;
};
// Generates: { onClick: ..., onFocus: ..., onBlur: ..., onHover: ... }
API Route Typing:
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Endpoint = '/users' | '/posts' | '/comments';
type Route = `${HTTPMethod} ${Endpoint}`;
const route: Route = 'GET /users'; // const invalid: Route = 'GET /invalid'; // BAD: Error
// Type-safe route matcher
function matchRoute(route: Route): void {
// TypeScript knows route is valid
}
Path Parameter Extraction (advanced):
type ExtractParams<T extends string> =
T extends `${infer _Start}:${infer Param}/${infer Rest}`
? { [K in Param | keyof ExtractParams<`/${Rest}`>]: string }
: T extends `${infer _Start}:${infer Param}`
? { [K in Param]: string }
: {};
type UserRoute = '/users/:userId/posts/:postId';
type Params = ExtractParams<UserRoute>; // { userId: string; postId: string }
function getPost(params: Params) {
console.log(params.userId, params.postId); // Type-safe
// console.log(params.invalid); // BAD: Error
}
Where it helps: Type-safe API clients, CSS-in-JS libraries, internationalization key validation, database query builders.
Performance: All computation happens at compile time - zero runtime cost.
7. The infer Keyword for Type Extraction
The infer keyword allows you to extract types from complex generic structures, enabling powerful type-level programming.
Extract Promise Value Type:
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type A = UnwrapPromise<Promise<string>>; // string
type B = UnwrapPromise<number>; // number
// Useful for typing async functions
declare function fetchData(): Promise<{ id: number; name: string }>;
type Data = UnwrapPromise<ReturnType<typeof fetchData>>;
// { id: number; name: string }
Extract Array Element Type:
type ElementType<T> = T extends (infer U)[] ? U : T;
type Items = ElementType<string[]>; // string
type Single = ElementType<number>; // number
// Useful for generic array utilities
function first<T extends any[]>(arr: T): ElementType<T> | undefined {
return arr[0];
}
Type-Safe API Client:
type APIResponse = {
'/users': { id: number; name: string }[];
'/posts': { id: number; title: string; body: string }[];
'/comments': { id: number; text: string; authorId: number }[];
};
type FetchResult<T extends keyof APIResponse> = APIResponse[T];
async function fetchAPI<T extends keyof APIResponse>(
endpoint: T
): Promise<FetchResult<T>> {
const res = await fetch(endpoint);
return res.json();
}
// Type-safe usage
const users = await fetchAPI('/users');
// type: { id: number; name: string }[]
const posts = await fetchAPI('/posts');
// type: { id: number; title: string; body: string }[]
Deep Partial Utility:
type DeepPartial<T> = T extends object
? { [P in keyof T]?: DeepPartial<T[P]> }
: T;
type Config = {
database: {
host: string;
port: number;
credentials: {
username: string;
password: string;
};
};
};
type PartialConfig = DeepPartial<Config>;
// All properties optional recursively
const config: PartialConfig = {
database: {
credentials: {
username: 'admin'
// password optional
}
// host and port optional
}
};
Use cases: Generic utility types, library authoring, complex type transformations.
Learning curve: Moderate to advanced, but the power is worth the investment.
Adoption in an Existing Codebase
Essential Configuration
A production-ready tsconfig.json that turns on every flag mentioned above:
{
"compilerOptions": {
// Standard strict flags
"strict": true,
// Additional strictness (NOT in strict mode!)
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"exactOptionalPropertyTypes": true,
// Modern module handling (TS 5.0+)
"verbatimModuleSyntax": true,
"moduleDetection": "force",
// Target modern JavaScript
"target": "ES2022",
"lib": ["ES2022", "DOM"],
// Better imports
"esModuleInterop": true,
"resolveJsonModule": true,
"allowJs": true,
// Performance
"skipLibCheck": true,
"incremental": true,
// Unused code detection
"noUnusedLocals": true,
"noUnusedParameters": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false
}
}
Migration Path
Three phases, in this order:
Phase 1: Enable strict mode
- Focus on
noImplicitAnyfirst: replaceanywithunknownor a proper type - Use
// @ts-expect-errorcomments temporarily for the cases you cannot resolve in the same pass
Phase 2: Add noUncheckedIndexedAccess
- Most fixes are adding
?.optional chaining orif (arr[i])guards - Read each error before silencing it. Some of them mark a real bug
Phase 3: Adopt the type-level patterns (ongoing)
- Introduce branded types for critical domain identifiers
- Replace switch statements with discriminated unions + exhaustiveness
- Use
satisfiesfor configuration objects - Gradual adoption as code is refactored
Performance Considerations
Compile Time: The extra flags add work to type checking, and the cost tracks codebase size more closely than it tracks how many of these patterns you use. Measure it on your own project before deciding it is too slow; incremental and skipLibCheck absorb most of it.
Runtime: Zero impact - all type information is erased during compilation.
Editor Responsiveness: The part that gets slow is deeply recursive conditional types, not satisfies or branded types. If the language server starts lagging, look at the infer chains first, then split the codebase with project references.
Common Pitfalls
Pitfall 1: Over-using Type Assertions
Type assertions with as bypass type checking entirely.
// Bad - no validation
const user = response as User;
// Good - validate first
function isUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'id' in obj &&
'email' in obj
);
}
const user = isUser(response) ? response : null;
Pitfall 2: Forgetting noUncheckedIndexedAccess Exists
Even with strict: true, indexed access isn’t safe unless you explicitly enable this option.
Pitfall 3: Complex infer Chains
Overly complex type utilities become hard to maintain. Break them into smaller, named types with clear comments.
// Hard to read
type Complex<T> = T extends { a: infer A extends { b: infer B } } ? B : never;
// Better - break down with clear names
type ExtractA<T> = T extends { a: infer A } ? A : never;
type ExtractB<T> = T extends { b: infer B } ? B : never;
type Result<T> = ExtractB<ExtractA<T>>;
When to Use Each Feature
| Feature | Best For | Avoid When |
|---|---|---|
satisfies | Config objects, const data | Dynamic runtime data |
noUncheckedIndexedAccess | All projects (should be default) | Legacy code with heavy array access |
| Branded types | Domain IDs, validated strings | Frequently converted between systems |
| Discriminated unions | State machines, API responses | Simple binary states (use boolean) |
| Template literals | String patterns, type-safe keys | Complex parsing logic |
infer | Library code, reusable utilities | One-off type manipulations |
| Type assertions | Validated external data | Internal code (use proper types) |
The default holds for most codebases: turn on noUncheckedIndexedAccess, then add branded types and exhaustive unions wherever a wrong value would corrupt data or skip a state. Override it where the cost outruns the benefit. A legacy module that indexes arrays in tight loops produces more noise than bugs under the flag, and a one-off type transformation rarely justifies a hand-written infer chain. If you change one line this week, make it the tsconfig one.
References
- TypeScript Handbook - Official comprehensive guide to TypeScript language features
- Narrowing - Handbook chapter covering type predicates, discriminated unions, and exhaustiveness checks with
never - Utility Types - Official reference for built-in utility type helpers
- Conditional Types - Handbook chapter on conditional types and the
inferkeyword - Template Literal Types - Handbook chapter on string manipulation at the type level
- TSConfig: noUncheckedIndexedAccess - Compiler option reference explaining what the flag adds to indexed access types
- TypeScript 4.9 Release Notes - Introduces the
satisfiesoperator with its original motivating examples - TypeScript 4.1 Release Notes - Introduces template literal types and key remapping in mapped types
- TypeScript 3.7 Release Notes - Introduces assertion functions and the
assertsreturn type syntax
Related posts
A lifecycle test for CDK stack layout: give a resource its own long-lived stack when it outlives any single deployer, then reach it by a well-known name.
How SOLID principles apply to modern JavaScript: practical examples with TypeScript, React hooks, and functional patterns, plus when they're overkill.
When to use service-based, domain-based, feature-based, or layer-based organization in AWS CDK projects, with decision frameworks and common pitfalls.
How factory functions, higher-order functions, and composition turn AWS CDK into a type-safe, reusable infrastructure toolkit that prevents configuration drift.
How Singleton, Factory, Builder, and Prototype patterns evolved in TypeScript: when ES modules replace singletons and when factory functions beat classes.