Type Safety
Why Type Safety Matters
Type safety prevents runtime errors by catching type mismatches at compile time. Production TypeScript applications require maximum type safety through strict compiler settings, proper type narrowing, branded types for domain validation, and discriminated unions for state management.
Core Benefits:
- Catch errors early: Type errors found at compile time, not in production
- Refactoring confidence: Type system guides changes across codebase
- Self-documenting code: Types serve as inline documentation
- IDE support: IntelliSense and autocomplete based on types
- Reduced bugs: 15% reduction in bug density (Microsoft Research)
Problem: TypeScript defaults allow unsafe patterns (any type, loose null checks, implicit conversions) that bypass type checking and allow runtime errors.
Solution: Enable strict compiler settings, use explicit types, leverage type narrowing, and employ branded types for domain constraints.
Standard Library First: Basic TypeScript Types
TypeScript provides built-in type system without external dependencies.
Basic Type Annotations
TypeScript infers types automatically but explicit annotations improve safety and documentation.
Pattern:
// Primitive types
let name: string = "Alice";
// => Explicit string type annotation
// => TypeScript enforces string operations only
let age: number = 30;
// => number type includes integers and floats
// => TypeScript prevents string concatenation
let isActive: boolean = true;
// => boolean type restricts to true/false
// => Prevents truthy/falsy confusion
// Arrays
let numbers: number[] = [1, 2, 3];
// => Array of numbers only
// => TypeScript prevents numbers.push("4")
let names: Array<string> = ["Alice", "Bob"];
// => Alternative array syntax (generic)
// => Equivalent to string[]
// Objects
interface User {
// => Define object shape
// => Properties required by default
id: number;
name: string;
email: string;
}
const user: User = {
// => Type annotation enforces interface
id: 1,
name: "Alice",
email: "alice@example.com",
};
// => Missing properties cause compile error
// => Extra properties cause compile error (strict)
// Functions
function greet(name: string): string {
// => Parameter type: string
// => Return type: string
// => TypeScript validates both
return `Hello, ${name}`;
}
const add = (a: number, b: number): number => {
// => Arrow function with types
// => Same type checking as function declaration
return a + b;
};
// Optional parameters
function createUser(name: string, age?: number): User {
// => age is optional (undefined allowed)
// => name is required
return {
id: Date.now(),
name: name,
email: `${name}@example.com`,
};
}Density: 18 code lines, 30 annotation lines = 1.67 density (within 1.0-2.25 target)
The any Type Problem
The any type disables type checking and defeats TypeScript’s purpose.
Anti-pattern:
function processData(data: any): any {
// => any accepts ANYTHING
// => No type safety at all
// => TypeScript cannot help
return data.transform().map().filter();
// => No autocomplete, no error checking
// => Runtime errors if methods don't exist
}
const result = processData("hello");
// => Accepts string (any accepts everything)
result.nonExistentMethod();
// => Compiles successfully (any allows anything)
// => Runtime error: nonExistentMethod is not a function
// any is viral: spreads through codebase
const value: any = getSomeValue();
// => value has type any
const transformed = value.toUpperCase();
// => transformed also has type any
// => Type safety lost completely
Problem: any makes TypeScript behave like JavaScript - no compile-time safety.
Limitations of basic types for production:
- No domain constraints:
stringaccepts any string (even invalid emails) - No validation: Types don’t enforce business rules
- Limited expressiveness: Cannot encode state machines in types
- Loose by default: TypeScript allows unsafe patterns without strict mode
- any escape hatch: Easy to bypass type system accidentally
- No null safety: Variables can be null/undefined without explicit handling
- Implicit any: Missing type annotations default to
any(unsafe)
When basic types suffice:
- Learning TypeScript fundamentals
- Small scripts (≤100 lines)
- Prototyping and exploration
- Code with no business logic
Production Pattern: TypeScript Strict Mode
Strict mode enables comprehensive type checking to catch more errors.
Enabling Strict Mode
Configure tsconfig.json for maximum safety.
Configuration (tsconfig.json):
{
"compilerOptions": {
"strict": true,
// => Enable all strict type checks
// => Shorthand for multiple flags below
// Individual strict flags (included in "strict: true")
"noImplicitAny": true,
// => Error on implicit any types
// => Forces explicit type annotations
"strictNullChecks": true,
// => null and undefined are separate types
// => Must explicitly handle null/undefined
"strictFunctionTypes": true,
// => Function parameters checked contravariantly
// => Prevents unsafe function assignments
"strictPropertyInitialization": true,
// => Class properties must be initialized
// => Prevents undefined properties
"strictBindCallApply": true,
// => Type-check bind/call/apply arguments
// => Catches argument mismatches
"noImplicitThis": true,
// => Error when this has implicit any type
// => Forces explicit this typing
"alwaysStrict": true,
// => Emit "use strict" in generated code
// => Enables JavaScript strict mode
// Additional recommended flags (not in "strict")
"noUncheckedIndexedAccess": true,
// => Indexed access returns T | undefined
// => Prevents out-of-bounds access bugs
"noImplicitReturns": true,
// => All code paths must return value
// => Catches missing return statements
"noFallthroughCasesInSwitch": true,
// => Switch cases must break/return
// => Prevents fall-through bugs
"noUnusedLocals": true,
// => Error on unused local variables
// => Catches dead code
"noUnusedParameters": true,
// => Error on unused parameters
// => Indicates incorrect function signature
"exactOptionalPropertyTypes": true
// => Optional properties cannot be set to undefined
// => Distinguishes absent vs undefined
}
}Density: 18 code lines, 38 annotation lines = 2.11 density (within 1.0-2.25 target)
Handling Null and Undefined
Strict null checks require explicit handling of nullable values.
Pattern:
interface User {
id: number;
name: string;
email: string | null;
// => email can be string or null
// => Must check before using
}
function sendEmail(user: User): void {
// => Function accepts User
// => Must handle nullable email
// ❌ ERROR: Object is possibly null
// console.log(user.email.toLowerCase());
// => TypeScript prevents this (strict mode)
// => Compile-time error prevents runtime crash
// ✅ Option 1: Type guard (if check)
if (user.email !== null) {
// => Narrow type to string
// => TypeScript knows email is string here
console.log(user.email.toLowerCase());
// => Safe to call string methods
}
// ✅ Option 2: Optional chaining
console.log(user.email?.toLowerCase());
// => Returns undefined if email is null
// => Safe navigation operator
// ✅ Option 3: Nullish coalescing
const email = user.email ?? "no-email@example.com";
// => Use default if email is null/undefined
console.log(email.toLowerCase());
// => email is guaranteed string
}
// Function that might not find user
function findUser(id: number): User | undefined {
// => Return type explicitly includes undefined
// => Callers must handle missing user
if (id === 1) {
return {
id: 1,
name: "Alice",
email: "alice@example.com",
};
}
return undefined;
// => Explicit undefined return
// => TypeScript tracks this possibility
}
// Calling code must handle undefined
const user = findUser(5);
// => user has type: User | undefined
// => Cannot access properties directly
// ❌ ERROR: Object is possibly undefined
// console.log(user.name);
// => TypeScript prevents unsafe access
// ✅ Type guard required
if (user !== undefined) {
// => Type narrowed to User
console.log(user.name);
// => Safe to access properties
}
// ✅ Or use optional chaining
console.log(user?.name);
// => Returns undefined if user is undefined
// => No runtime error
Density: 26 code lines, 42 annotation lines = 1.62 density (within 1.0-2.25 target)
Type Narrowing with Type Guards
Type guards narrow union types to specific types.
Pattern:
// typeof type guard (primitives)
function processValue(value: string | number) {
// => Union type: string OR number
// => Must narrow before type-specific operations
if (typeof value === "string") {
// => typeof guard narrows to string
// => TypeScript knows value is string here
return value.toUpperCase();
// => String methods available
}
// TypeScript knows value is number here
return value.toFixed(2);
// => Number methods available
// => Type narrowing eliminates impossible cases
}
// instanceof type guard (classes)
class Dog {
bark(): void {
console.log("Woof!");
}
}
class Cat {
meow(): void {
console.log("Meow!");
}
}
function makeSound(animal: Dog | Cat) {
// => Union of class types
if (animal instanceof Dog) {
// => instanceof narrows to Dog
animal.bark();
// => Dog methods available
} else {
// => TypeScript knows animal is Cat
animal.meow();
// => Cat methods available
}
}
// in operator type guard (properties)
interface Bird {
fly(): void;
wingspan: number;
}
interface Fish {
swim(): void;
finCount: number;
}
function move(animal: Bird | Fish) {
// => Discriminate by property presence
if ("fly" in animal) {
// => 'in' checks property existence
// => Narrows to Bird
animal.fly();
console.log(animal.wingspan);
// => Bird properties available
} else {
// => Must be Fish
animal.swim();
console.log(animal.finCount);
// => Fish properties available
}
}
// Custom type guard function
function isString(value: unknown): value is string {
// => Custom type guard with type predicate
// => 'value is string' is type predicate
// => TypeScript trusts return value
return typeof value === "string";
// => Implementation determines narrowing
}
function processUnknown(value: unknown) {
// => unknown type: safest top type
// => Must narrow before any operation
if (isString(value)) {
// => Custom guard narrows unknown to string
console.log(value.toUpperCase());
// => String operations allowed
}
}Density: 36 code lines, 43 annotation lines = 1.19 density (within 1.0-2.25 target)
Discriminated Unions for State Management
Discriminated unions use literal types to narrow complex unions.
Pattern:
// Define states with discriminant property
type LoadingState = {
// => Loading state
status: "loading";
// => Literal type as discriminant
// => TypeScript uses this to narrow union
};
type SuccessState = {
// => Success state
status: "success";
// => Different literal value
data: string[];
// => Success-specific property
};
type ErrorState = {
// => Error state
status: "error";
// => Different literal value
error: string;
// => Error-specific property
};
// Union of all states
type RequestState = LoadingState | SuccessState | ErrorState;
// => Union type with discriminant
// => 'status' field discriminates
function renderUI(state: RequestState): string {
// => Single parameter, three possible states
// => Must handle all cases
// TypeScript narrows based on discriminant
switch (state.status) {
case "loading":
// => state is LoadingState here
return "Loading...";
// => No data/error properties available
case "success":
// => state is SuccessState here
// => TypeScript knows data exists
return `Data: ${state.data.join(", ")}`;
// => data property available (type-safe)
case "error":
// => state is ErrorState here
// => TypeScript knows error exists
return `Error: ${state.error}`;
// => error property available (type-safe)
default:
// => Exhaustiveness check
const exhaustive: never = state;
// => If all cases handled, state is never
// => Compile error if case missing
throw new Error(`Unhandled state: ${exhaustive}`);
}
}
// Usage
const loadingState: RequestState = { status: "loading" };
// => Create loading state
console.log(renderUI(loadingState));
// => Renders loading UI
const successState: RequestState = {
status: "success",
data: ["item1", "item2"],
};
// => Create success state
console.log(renderUI(successState));
// => Renders success UI with data
const errorState: RequestState = {
status: "error",
error: "Network timeout",
};
// => Create error state
console.log(renderUI(errorState));
// => Renders error UI
Density: 29 code lines, 43 annotation lines = 1.48 density (within 1.0-2.25 target)
Production benefits:
- Exhaustiveness checking: Compiler ensures all cases handled
- Type-safe property access: Cannot access wrong state’s properties
- Refactor-friendly: Adding state requires handling in all switches
- Self-documenting: State transitions explicit in types
Trade-offs:
- More verbose: Requires explicit state types
- Learning curve: Understanding discriminated unions takes time
- Refactoring impact: Changing discriminant affects all switches
When to use strict mode:
- All production code (always enable strict mode)
- Team projects (consistent safety across codebase)
- Long-lived applications (type safety pays off over time)
Production Pattern: Branded Types for Domain Validation
Branded types enforce domain constraints at compile time.
The Primitive Obsession Problem
Primitives don’t encode domain meaning or constraints.
Anti-pattern:
// Primitives look the same
function sendEmail(email: string, userId: string): void {
// => Both parameters are strings
// => Easy to swap accidentally
console.log(`Sending email to ${email} for user ${userId}`);
}
const userEmail = "alice@example.com";
const userId = "user-123";
sendEmail(userId, userEmail);
// => ❌ Arguments swapped!
// => TypeScript cannot catch this
// => Both are strings (type-compatible)
// => Runtime bug: sending to "user-123"
Problem: Primitive types don’t encode domain semantics.
Branded Type Pattern
Brand primitives with unique symbols for type distinction.
Pattern:
// Branded type using symbol
declare const EmailBrand: unique symbol;
// => Declare brand symbol (doesn't exist at runtime)
// => unique symbol creates distinct type
type Email = string & { [EmailBrand]: never };
// => Email is string AND has brand property
// => Intersection type makes it distinct
// => Brand property ensures type incompatibility
declare const UserIdBrand: unique symbol;
type UserId = string & { [UserIdBrand]: never };
// => Distinct brand for UserId
// => UserId and Email are incompatible types
// Smart constructor enforces validation
function createEmail(input: string): Email {
// => Factory function validates and brands
// => Only way to create valid Email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// => Email validation regex
// => Production: Use library like validator.js
if (!emailRegex.test(input)) {
// => Validate input
throw new Error(`Invalid email: ${input}`);
// => Throw if invalid
// => Prevents creating invalid Email
}
return input as Email;
// => Type assertion (safe after validation)
// => Cast string to Email brand
// => TypeScript trusts this assertion
}
function createUserId(input: string): UserId {
// => Factory for UserId
if (input.length === 0) {
throw new Error("UserId cannot be empty");
}
return input as UserId;
// => Brand as UserId
}
// Type-safe function
function sendEmail(email: Email, userId: UserId): void {
// => Requires branded types
// => Cannot pass regular strings
// => Cannot swap parameters
console.log(`Sending email to ${email} for user ${userId}`);
}
// Usage
const email = createEmail("alice@example.com");
// => email has type Email (branded)
const userId = createUserId("user-123");
// => userId has type UserId (branded)
sendEmail(email, userId);
// => ✅ Correct order, type-safe
// sendEmail(userId, email);
// => ❌ TYPE ERROR: Argument types don't match
// => TypeScript prevents swap at compile time
// sendEmail("alice@example.com", "user-123");
// => ❌ TYPE ERROR: Requires branded types
// => Cannot pass raw strings
// => Must use factory functions
Density: 29 code lines, 42 annotation lines = 1.45 density (within 1.0-2.25 target)
Branded Numbers for Units
Brand numbers to distinguish units and prevent calculation errors.
Pattern:
declare const MetersBrand: unique symbol;
type Meters = number & { [MetersBrand]: never };
// => Distance in meters (branded)
declare const KilometersBrand: unique symbol;
type Kilometers = number & { [KilometersBrand]: never };
// => Distance in kilometers (branded)
declare const SecondsBrand: unique symbol;
type Seconds = number & { [SecondsBrand]: never };
// => Time in seconds (branded)
// Factory functions
function meters(value: number): Meters {
// => Create Meters from number
if (value < 0) {
throw new Error("Distance cannot be negative");
}
return value as Meters;
}
function kilometers(value: number): Kilometers {
if (value < 0) {
throw new Error("Distance cannot be negative");
}
return value as Kilometers;
}
function seconds(value: number): Seconds {
if (value < 0) {
throw new Error("Time cannot be negative");
}
return value as Seconds;
}
// Unit conversion functions
function metersToKilometers(m: Meters): Kilometers {
// => Convert Meters to Kilometers
// => Type-safe conversion
return kilometers(m / 1000);
// => Returns branded Kilometers
}
function kilometersToMeters(km: Kilometers): Meters {
return meters(km * 1000);
}
// Type-safe calculations
function calculateSpeed(distance: Kilometers, time: Seconds): number {
// => Requires specific units
// => Cannot pass Meters instead of Kilometers
// => Cannot pass wrong unit
return distance / time;
// => Result: km/s (unbranded for now)
}
// Usage
const distance = kilometers(100);
// => 100 kilometers (branded)
const time = seconds(3600);
// => 3600 seconds (branded)
const speed = calculateSpeed(distance, time);
// => ✅ Type-safe: 100km / 3600s
const distanceInMeters = meters(5000);
// => 5000 meters (different brand)
// calculateSpeed(distanceInMeters, time);
// => ❌ TYPE ERROR: Expects Kilometers, got Meters
// => Prevents unit mixing at compile time
// Must convert first
const converted = metersToKilometers(distanceInMeters);
calculateSpeed(converted, time);
// => ✅ Explicit conversion required
Density: 29 code lines, 33 annotation lines = 1.14 density (within 1.0-2.25 target)
Production benefits:
- Compile-time validation: Invalid values caught at compile time
- Self-documenting: Type names encode domain meaning
- Prevents mixing: Cannot mix incompatible branded types
- Zero runtime cost: Brands erased during compilation
Trade-offs:
- More code: Requires factory functions for each brand
- Type assertions: Need
ascasts (validated by factory) - Not fool-proof:
ascan still bypass validation if misused
When to use branded types:
- Domain-specific types (Email, URL, UserId)
- Unit-sensitive calculations (Meters, Seconds, Celsius)
- IDs that must not be mixed (OrderId vs UserId)
- High-risk operations (Money, Percentage)
Type Safety Progression Diagram
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC
%% All colors are color-blind friendly and meet WCAG AA contrast standards
graph TB
A[Basic TypeScript Types] -->|Enable strict mode| B[Strict Type Checking]
B -->|Domain validation| C[Branded Types]
B -->|Complex states| D[Discriminated Unions]
A:::standard
B:::framework
C:::production
D:::production
classDef standard fill:#CC78BC,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef framework fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef production fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
subgraph Standard[" Standard TypeScript "]
A
end
subgraph Strict[" Strict Mode "]
B
end
subgraph Production[" Production Patterns "]
C
D
end
style Standard fill:#F0F0F0,stroke:#CC78BC,stroke-width:3px
style Strict fill:#F0F0F0,stroke:#0173B2,stroke-width:3px
style Production fill:#F0F0F0,stroke:#029E73,stroke-width:3px
Production Best Practices
Avoid any Type
Replace any with safer alternatives.
Pattern:
// ❌ BAD: any defeats type system
function processBad(data: any): any {
return data.map((x: any) => x * 2);
// => No type safety at all
}
// ✅ GOOD: Generic preserves types
function processGeneric<T>(data: T[], transform: (x: T) => T): T[] {
// => Generic function preserves type information
// => T is inferred from usage
return data.map(transform);
// => Type-safe transformation
}
// ✅ GOOD: unknown for truly unknown types
function parseJSON(json: string): unknown {
// => unknown requires narrowing before use
// => Safer than any
return JSON.parse(json);
// => Return type unknown (caller must validate)
}
const result = parseJSON('{"name": "Alice"}');
// => result has type unknown
// Must narrow before using
if (typeof result === "object" && result !== null && "name" in result) {
// => Type guard narrows unknown
console.log((result as { name: string }).name);
// => Safe after validation
}Use Type Predicates
Type predicates enable reusable type narrowing.
Pattern:
interface User {
type: "user";
name: string;
email: string;
}
interface Admin {
type: "admin";
name: string;
permissions: string[];
}
type Account = User | Admin;
// Type predicate function
function isAdmin(account: Account): account is Admin {
// => Type predicate: 'account is Admin'
// => TypeScript trusts this function for narrowing
return account.type === "admin";
// => Runtime check determines type
}
function getPermissions(account: Account): string[] {
// => Union type parameter
if (isAdmin(account)) {
// => Type predicate narrows to Admin
return account.permissions;
// => Admin properties available
}
return [];
// => User has no permissions
}Leverage Const Assertions
Const assertions preserve literal types.
Pattern:
// Without const assertion
const config1 = {
env: "production",
port: 3000,
};
// => env has type: string (widened)
// => port has type: number (widened)
// With const assertion
const config2 = {
env: "production",
port: 3000,
} as const;
// => env has type: "production" (literal)
// => port has type: 3000 (literal)
// => All properties readonly
function connect(env: "production" | "development") {
// => Expects literal type
console.log(`Connecting to ${env}`);
}
// connect(config1.env);
// => ❌ TYPE ERROR: string not assignable to literal union
connect(config2.env);
// => ✅ "production" literal matches
Trade-offs and When to Use Each
Basic Types (Standard TypeScript)
Use when:
- Learning TypeScript basics
- Simple scripts (no domain logic)
- Prototyping and exploration
- Code that will be rewritten
Avoid when:
- Production applications (use strict mode)
- Domain-specific logic (use branded types)
- Complex state management (use discriminated unions)
Strict Mode (Production Standard)
Use when:
- All production code (always enable)
- Team projects (consistency)
- Long-lived applications
- Refactoring legacy code
Avoid when:
- Never (always use strict mode in production)
Branded Types
Use when:
- Domain-specific values (Email, UserId, URL)
- Unit-sensitive calculations (Meters, Seconds)
- Preventing ID mixing (OrderId vs UserId)
- High-risk operations (Money, Percentage)
Avoid when:
- Simple applications (overkill for basic types)
- Prototyping (adds development overhead)
- Performance-critical code (factory overhead)
Discriminated Unions
Use when:
- State machines (loading/success/error)
- Complex conditional logic
- Exhaustive case handling required
- API response modeling
Avoid when:
- Simple boolean flags suffice
- State space is unbounded
- Performance critical (adds runtime checks)
Common Pitfalls
Pitfall 1: Using any Instead of unknown
Problem: any disables all type checking.
Solution: Use unknown and narrow types.
// ❌ BAD
function process(data: any) {
return data.toUpperCase();
// => Compiles, runtime error if not string
}
// ✅ GOOD
function process(data: unknown) {
if (typeof data === "string") {
return data.toUpperCase();
// => Type-safe after narrowing
}
throw new Error("Expected string");
}Pitfall 2: Not Handling null/undefined
Problem: Strict mode requires explicit null handling.
Solution: Use optional chaining and nullish coalescing.
// ❌ BAD (strict mode error)
function getLength(str: string | null): number {
return str.length;
// => Error: Object is possibly null
}
// ✅ GOOD
function getLength(str: string | null): number {
return str?.length ?? 0;
// => Returns 0 if null, length otherwise
}Pitfall 3: Forgetting Exhaustiveness Checks
Problem: Missing switch cases cause runtime errors.
Solution: Use never type for exhaustiveness.
type Status = "pending" | "approved" | "rejected";
// ❌ BAD: Missing case
function handleStatus(status: Status): string {
switch (status) {
case "pending":
return "Waiting";
case "approved":
return "Approved";
// Missing "rejected" case!
}
// => Returns undefined for "rejected"
}
// ✅ GOOD: Exhaustiveness check
function handleStatus(status: Status): string {
switch (status) {
case "pending":
return "Waiting";
case "approved":
return "Approved";
case "rejected":
return "Rejected";
default:
const exhaustive: never = status;
// => Compile error if case missing
throw new Error(`Unhandled status: ${exhaustive}`);
}
}Pitfall 4: Type Assertions Without Validation
Problem: Type assertions bypass type checking.
Solution: Only assert after validation.
// ❌ BAD: Unsafe assertion
const data = JSON.parse(json) as User;
// => No validation, runtime errors possible
// ✅ GOOD: Validate before asserting
function isUser(value: unknown): value is User {
return typeof value === "object" && value !== null && "id" in value && "name" in value && "email" in value;
}
const data = JSON.parse(json);
if (isUser(data)) {
// => Safe to use as User
console.log(data.name);
}Summary
Type safety prevents runtime errors by catching type mismatches at compile time. Basic TypeScript provides type annotations and inference, strict mode enforces comprehensive null checking and eliminates implicit any, and branded types encode domain constraints in the type system.
Progression path:
- Learn with basic types: Understand TypeScript fundamentals
- Production with strict mode: Enable all strict compiler flags
- Domain safety with branded types: Encode business rules in types
- State safety with discriminated unions: Model complex states
Production checklist:
- ✅ Strict mode enabled (all flags)
- ✅ No
anytypes (useunknowninstead) - ✅ Null checks explicit (no implicit undefined)
- ✅ Branded types for domain values (Email, UserId)
- ✅ Discriminated unions for states (loading/success/error)
- ✅ Type predicates for narrowing
- ✅ Exhaustiveness checks in switches
- ✅ Validation before type assertions
Choose type safety patterns based on risk: strict mode for all code, branded types for domain logic, discriminated unions for state machines.