Beginner
Example 1: Basic Types and Type Annotations
TypeScript is a statically-typed superset of JavaScript that adds compile-time type checking. You write types as annotations that the compiler verifies before generating JavaScript.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph LR
A["TypeScript Code<br/>.ts files"] --> B["TypeScript Compiler<br/>(tsc)"]
B --> C["JavaScript Code<br/>.js files"]
C --> D["Runtime<br/>(Node.js/Browser)"]
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#DE8F05,stroke:#000,color:#fff
style C fill:#029E73,stroke:#000,color:#fff
style D fill:#CC78BC,stroke:#000,color:#fff
Code:
// PRIMITIVE TYPES
let age: number = 25; // => age = 25 (type: number)
let name: string = "Alice"; // => name = "Alice" (type: string)
let isActive: boolean = true; // => isActive = true (type: boolean)
let value: null = null; // => value = null (type: null)
let notDefined: undefined = undefined; // => notDefined = undefined (type: undefined)
console.log(age, name, isActive); // => Output: 25 Alice true
// TYPE INFERENCE
let inferredNumber = 42; // => inferredNumber = 42 (type: number, inferred)
// => TypeScript infers type from initial value
let inferredString = "TypeScript"; // => inferredString = "TypeScript" (type: string, inferred)
// TYPE ANNOTATIONS OVERRIDE INFERENCE
let explicit: number = 100; // => explicit = 100 (type: number, explicit)
// => Annotation required for uninitialized variables
// Arrays
let numbers: number[] = [1, 2, 3]; // => numbers = [1, 2, 3] (type: number[])
let strings: Array<string> = ["a", "b"]; // => strings = ["a", "b"] (generic array syntax)
console.log(numbers[0]); // => Output: 1
Key Takeaway: TypeScript adds type annotations (: type) to JavaScript variables. The compiler infers types when possible but allows explicit annotations for clarity or when inference isn’t available.
Why It Matters: Type annotations catch errors at compile time rather than runtime. The compile-time checking means fewer runtime errors, better IDE autocomplete, and safer refactoring compared to JavaScript. This prevents production crashes and makes large codebases easier to maintain.
Example 2: Functions and Parameter Types
TypeScript functions can have typed parameters and return values. Arrow functions and traditional function syntax both support type annotations.
Code:
// FUNCTION WITH TYPED PARAMETERS AND RETURN TYPE
function add(a: number, b: number): number {
// => Parameters: a, b (both number)
// => Return type: number (explicit)
return a + b; // => Returns sum (must be number)
}
let result = add(5, 3); // => result = 8 (type: number, inferred from return)
console.log(result); // => Output: 8
// ARROW FUNCTION WITH TYPES
const multiply = (x: number, y: number): number => {
// => Arrow function syntax
// => Same type annotations as regular function
return x * y; // => Returns product
};
let product = multiply(4, 7); // => product = 28
console.log(product); // => Output: 28
// OPTIONAL PARAMETERS
function greet(name: string, greeting?: string): string {
// => greeting is optional (?: syntax)
// => Optional params are type | undefined
if (greeting) {
// => Check if greeting provided
return `${greeting}, ${name}!`; // => greeting = "Hello", returns "Hello, Alice!"
}
return `Hi, ${name}!`; // => Default greeting when no arg
}
console.log(greet("Alice", "Hello")); // => Output: Hello, Alice!
console.log(greet("Bob")); // => Output: Hi, Bob! (greeting undefined)
// DEFAULT PARAMETERS
function createUser(name: string, role: string = "user"): string {
// => role defaults to "user"
// => Default value sets type implicitly
return `${name} (${role})`; // => Template literal concatenation
}
console.log(createUser("Charlie")); // => Output: Charlie (user)
console.log(createUser("Diana", "admin")); // => Output: Diana (admin)
Key Takeaway: Use : type after parameters and before function body for return types. Optional parameters use ?: syntax, and default parameters provide values when arguments are omitted.
Why It Matters: Typed function signatures prevent entire categories of bugs common in JavaScript—calling functions with wrong argument counts, passing wrong types, or assuming return values that don’t exist. Typed props prevent passing invalid data to components. Typed request handlers catch invalid request parsing before deployment. TypeScript’s function overloads enable defining multiple call signatures for flexible APIs while maintaining type safety.
Example 3: Interfaces for Object Shapes
Interfaces define the structure of objects—required properties, optional properties, and methods. They’re TypeScript’s primary tool for describing object contracts.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
A["Interface Definition"] --> B["Required Properties"]
A --> C["Optional Properties"]
A --> D["Methods"]
B --> E["Compile-Time Check"]
C --> E
D --> E
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#DE8F05,stroke:#000,color:#fff
style C fill:#029E73,stroke:#000,color:#fff
style D fill:#CC78BC,stroke:#000,color:#fff
style E fill:#CA9161,stroke:#000,color:#fff
Code:
// INTERFACE DEFINITION
interface User {
// => Interface defines object shape
id: number; // => Required property (must exist)
name: string; // => Required property
email?: string; // => Optional property (?: syntax)
isActive: boolean; // => Required property
}
// OBJECT CONFORMING TO INTERFACE
const user1: User = {
// => Object must match User interface
id: 1, // => id provided (required)
name: "Alice", // => name provided (required)
email: "alice@example.com", // => email provided (optional)
isActive: true, // => isActive provided (required)
}; // => All required props present, compiles successfully
console.log(user1.name); // => Output: Alice
// OMITTING OPTIONAL PROPERTY
const user2: User = {
// => email omitted (allowed because optional)
id: 2, // => id provided
name: "Bob", // => name provided
isActive: false, // => isActive provided
}; // => Compiles: optional props can be omitted
console.log(user2.email); // => Output: undefined (optional prop omitted)
// INTERFACE WITH METHODS
interface Calculator {
// => Interface can include methods
add(a: number, b: number): number; // => Method signature (not implementation)
subtract(a: number, b: number): number;
}
const calc: Calculator = {
// => Object implementing Calculator interface
add(a, b) {
// => Method implementation
return a + b; // => Must return number (matches signature)
},
subtract(a, b) {
// => Second method implementation
return a - b; // => Must return number
},
};
console.log(calc.add(10, 5)); // => Output: 15
Key Takeaway: Interfaces define object contracts with required properties, optional properties (using ?:), and method signatures. Objects must match the interface structure to satisfy the type.
Why It Matters: Interfaces enable structural typing—objects are validated by shape, not inheritance. This powers TypeScript’s “duck typing” philosophy: if it walks like a User and talks like a User, it’s a User. Component props can be interfaces defining expected data shapes. Request/Response types can be interfaces ensuring middleware receives correct objects. Teams use interfaces as contracts between modules, catching integration bugs at compile time rather than runtime.
Example 4: Type Aliases and Union Types
Type aliases create reusable type definitions. Union types (|) allow values to be one of several types, enabling flexible yet type-safe APIs.
Code:
// TYPE ALIAS FOR PRIMITIVE
type ID = number | string; // => ID can be number OR string (union type)
// => Type alias creates reusable type name
let userId: ID = 123; // => userId = 123 (number, matches ID)
let productId: ID = "prod-456"; // => productId = "prod-456" (string, matches ID)
console.log(userId, productId); // => Output: 123 prod-456
// TYPE ALIAS FOR OBJECT
type Point = {
// => Type alias for object shape
x: number; // => Required property
y: number; // => Required property
};
const origin: Point = { x: 0, y: 0 }; // => origin matches Point type
console.log(origin); // => Output: { x: 0, y: 0 }
// UNION TYPE FOR FUNCTION PARAMETERS
function printId(id: number | string): void {
// => id can be number OR string
// => Return type: void (no return value)
if (typeof id === "string") {
// => Type guard: narrows id to string
// => Inside this block, id is string
console.log(`String ID: ${id.toUpperCase()}`); // => toUpperCase available (string method)
// => Output: String ID: PROD-456
} else {
// => Type guard: narrows id to number
console.log(`Number ID: ${id}`); // => id is number here
// => Output: Number ID: 123
}
}
printId(123); // => Calls with number
printId("prod-456"); // => Calls with string
// UNION TYPE WITH LITERAL TYPES
type Status = "pending" | "approved" | "rejected"; // => Literal type union (specific strings)
// => Only these 3 values allowed
let orderStatus: Status = "pending"; // => orderStatus = "pending" (matches literal)
console.log(orderStatus); // => Output: pending
// This would be a compile error:
// orderStatus = "unknown"; // => ERROR: "unknown" not in Status union
Key Takeaway: Type aliases (type Name = ...) create reusable type definitions. Union types (A | B) allow values to be one of several types. Use typeof checks to narrow union types to specific branches.
Why It Matters: Union types enable flexible APIs while maintaining type safety. Actions can use union types to represent different shapes. API responses can use Success | Error unions for result types. Code generators can create union types for schema variations. This pattern eliminates defensive programming—instead of checking if (response.error) everywhere, TypeScript enforces handling all cases. The type system guides you through every code path, preventing forgotten edge cases that cause bugs.
Example 5: Arrays and Tuples
Arrays hold multiple values of the same type. Tuples are fixed-length arrays where each position has a specific type, useful for returning multiple values.
Code:
// ARRAY OF NUMBERS
let numbers: number[] = [1, 2, 3, 4, 5]; // => numbers = [1, 2, 3, 4, 5] (type: number[])
// => All elements must be numbers
numbers.push(6); // => Appends 6 to end
// => numbers = [1, 2, 3, 4, 5, 6]
console.log(numbers); // => Output: [1, 2, 3, 4, 5, 6]
// GENERIC ARRAY SYNTAX
let strings: Array<string> = ["a", "b", "c"]; // => Alternative syntax (Array<T>)
// => Equivalent to string[]
console.log(strings[0]); // => Output: a
// TUPLE - FIXED LENGTH WITH SPECIFIC TYPES
let person: [string, number] = ["Alice", 30]; // => Tuple: [string, number]
// => Position 0 must be string, position 1 must be number
console.log(person[0]); // => Output: Alice (string)
console.log(person[1]); // => Output: 30 (number)
// TUPLE DESTRUCTURING
let [name, age] = person; // => Destructures tuple into variables
// => name = "Alice" (string), age = 30 (number)
console.log(name, age); // => Output: Alice 30
// TUPLE FOR FUNCTION RETURN
function getCoordinates(): [number, number] {
// => Returns tuple [number, number]
return [10, 20]; // => Returns [10, 20]
}
let [x, y] = getCoordinates(); // => Destructures into x, y
// => x = 10, y = 20
console.log(x, y); // => Output: 10 20
// ARRAY OF OBJECTS
interface Product {
id: number;
name: string;
}
let products: Product[] = [
// => Array of Product interfaces
{ id: 1, name: "Laptop" }, // => Each element must match Product shape
{ id: 2, name: "Mouse" },
];
console.log(products[0].name); // => Output: Laptop
Key Takeaway: Arrays hold multiple values of one type (type[] or Array<type>). Tuples are fixed-length arrays with specific types per position ([type1, type2]), useful for multi-value returns.
Why It Matters: Tuples solve JavaScript’s multi-value return problem without creating wrapper objects. Hooks can return tuples for state management. Coordinate systems return [x, y] tuples. Database queries can return [error, result] tuples. This pattern is more efficient than objects for simple multi-value returns and enables positional destructuring. Array typing prevents mixing incompatible types—no more [1, "two", true] causing runtime errors.
Example 6: Enums for Named Constants
Enums define a set of named constants. Numeric enums auto-increment, string enums require explicit values. They improve code readability by replacing magic numbers/strings.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph LR
A["Enum Declaration"] --> B["Numeric Enum<br/>(0, 1, 2...)"]
A --> C["String Enum<br/>(explicit values)"]
B --> D["Runtime Object"]
C --> D
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#DE8F05,stroke:#000,color:#fff
style C fill:#029E73,stroke:#000,color:#fff
style D fill:#CC78BC,stroke:#000,color:#fff
Code:
// NUMERIC ENUM
enum Direction {
// => Numeric enum (default)
Up, // => Up = 0 (auto-assigned)
Down, // => Down = 1 (auto-increments)
Left, // => Left = 2
Right, // => Right = 3
}
let move: Direction = Direction.Up; // => move = 0 (Direction.Up)
console.log(move); // => Output: 0
// REVERSE MAPPING
console.log(Direction[0]); // => Output: Up (reverse lookup: number -> name)
// => Numeric enums support bidirectional mapping
// ENUM WITH CUSTOM START VALUE
enum Status {
// => Custom starting value
Pending = 1, // => Pending = 1 (explicit)
Approved, // => Approved = 2 (auto-increments)
Rejected, // => Rejected = 3
}
console.log(Status.Approved); // => Output: 2
// STRING ENUM
enum LogLevel {
// => String enum (no auto-increment)
Error = "ERROR", // => Error = "ERROR" (explicit value required)
Warning = "WARNING", // => Warning = "WARNING"
Info = "INFO", // => Info = "INFO"
Debug = "DEBUG", // => Debug = "DEBUG"
}
function log(level: LogLevel, message: string): void {
// => level must be LogLevel enum
console.log(`[${level}] ${message}`); // => Template literal with enum value
}
log(LogLevel.Error, "Database connection failed"); // => Output: [ERROR] Database connection failed
log(LogLevel.Info, "Server started"); // => Output: [INFO] Server started
// CONST ENUM (COMPILE-TIME ONLY)
const enum Color {
// => const enum (no runtime object)
Red = "RED", // => Values inlined at compile time
Green = "GREEN",
Blue = "BLUE",
}
let favorite: Color = Color.Blue; // => Compiles to: let favorite = "BLUE"
// => No runtime Color object created
console.log(favorite); // => Output: BLUE
Key Takeaway: Numeric enums auto-increment from 0 (or custom start). String enums require explicit values. Use enums to replace magic numbers/strings with named constants for better readability.
Why It Matters: Enums prevent typos in string literals that cause runtime bugs. API status codes can become HttpStatus.OK instead of 200. Action types can become ActionType.FETCH_USER instead of "FETCH_USER". Connection states can become ConnectionState.Connected instead of magic numbers. The compiler catches invalid enum values at build time. String enums are often preferred over numeric enums because they’re more debuggable—seeing "ERROR" in logs is clearer than 0.
Example 7: Type Assertions and Type Casting
Type assertions tell the compiler to treat a value as a specific type when you know more than TypeScript can infer. Use sparingly as they bypass type checking.
Code:
// TYPE ASSERTION WITH 'as' SYNTAX
let value: any = "Hello, TypeScript"; // => value has type 'any' (no type checking)
// => any type disables type safety
let length: number = (value as string).length; // => Asserts value is string
// => TypeScript allows .length access
// => length = 17 (string length)
console.log(length); // => Output: 17
// ANGLE BRACKET SYNTAX (ALTERNATIVE)
let length2: number = (<string>value).length; // => Alternative syntax (not in JSX)
// => Equivalent to 'as string'
// => Avoid in React (.tsx files)
console.log(length2); // => Output: 17
// TYPE ASSERTION WITH DOM ELEMENTS
const input = document.getElementById("username"); // => Returns HTMLElement | null
// => Generic element type
if (input) {
// => Null check required
// Type assertion to specific element type
const inputElement = input as HTMLInputElement; // => Assert it's HTMLInputElement
// => Enables access to .value property
inputElement.value = "Alice"; // => Sets input value
// => .value only exists on HTMLInputElement
console.log(inputElement.value); // => Output: Alice
}
// NON-NULL ASSERTION OPERATOR
function getValue(): string | null {
// => May return null
return "data"; // => Returns "data" (not null)
}
let result = getValue(); // => result type: string | null
let upperCase = result!.toUpperCase(); // => ! asserts result is not null
// => Dangerous: runtime error if actually null
// => upperCase = "DATA"
console.log(upperCase); // => Output: DATA
// CONST ASSERTION
let point = { x: 10, y: 20 } as const; // => 'as const' makes object readonly
// => point type: { readonly x: 10; readonly y: 20 }
// => Properties become literal types
console.log(point.x); // => Output: 10
// point.x = 15; // => ERROR: Cannot assign to readonly property
Key Takeaway: Type assertions (as Type or <Type>) tell TypeScript to treat a value as a specific type. Use ! to assert non-null values. Use as const for readonly objects with literal types.
Why It Matters: Type assertions are necessary when working with DOM APIs (TypeScript can’t know getElementById returns HTMLInputElement), external libraries without types, or migrating JavaScript to TypeScript. However, they’re dangerous—they bypass type checking and can cause runtime errors if wrong. Minimize assertions through better typing (generics, type guards, proper interfaces). The non-null assertion ! is particularly risky and should be avoided unless you’re certain the value exists.
Example 8: Classes and Constructors
TypeScript classes add access modifiers (public, private, protected), typed properties, and constructor parameter properties to JavaScript classes.
Code:
// CLASS WITH TYPED PROPERTIES
class Person {
// => Class definition
name: string; // => Public property (default)
private age: number; // => Private property (only accessible in class)
protected email: string; // => Protected property (accessible in subclasses)
constructor(name: string, age: number, email: string) {
// => Constructor with typed params
this.name = name; // => Initialize name property
this.age = age; // => Initialize private age
this.email = email; // => Initialize protected email
}
getAge(): number {
// => Public method returning number
return this.age; // => Access private property within class
}
introduce(): void {
// => Method with no return value
console.log(`Hi, I'm ${this.name}, ${this.age} years old`);
}
}
const person = new Person("Alice", 30, "alice@example.com"); // => Create instance
// => Calls constructor
person.introduce(); // => Output: Hi, I'm Alice, 30 years old
console.log(person.name); // => Output: Alice (public property accessible)
// console.log(person.age); // => ERROR: age is private
console.log(person.getAge()); // => Output: 30 (access via public method)
// CONSTRUCTOR PARAMETER PROPERTIES
class User {
// => Shorthand syntax
constructor(
public id: number, // => public modifier creates + initializes property
public username: string, // => Equivalent to: this.username = username
private password: string, // => private property created automatically
) {} // => Empty body (initialization handled by modifiers)
}
const user = new User(1, "alice", "secret123"); // => Creates User instance
console.log(user.id, user.username); // => Output: 1 alice
// console.log(user.password); // => ERROR: password is private
// CLASS WITH STATIC MEMBERS
class MathHelper {
// => Utility class
static PI: number = 3.14159; // => Static property (shared across instances)
static square(x: number): number {
// => Static method (called on class, not instance)
return x * x; // => Returns x squared
}
}
console.log(MathHelper.PI); // => Output: 3.14159 (access via class name)
console.log(MathHelper.square(5)); // => Output: 25 (call via class name)
Key Takeaway: Classes support typed properties, access modifiers (public, private, protected), and constructor parameter properties for concise initialization. Use static for class-level members shared across instances.
Why It Matters: TypeScript’s class system bridges object-oriented programming and JavaScript. Frameworks can use classes for components and services. Classes work with decorators for controllers and providers. The access modifiers enforce encapsulation—private properties can’t leak outside the class, preventing accidental mutation. Constructor parameter properties reduce boilerplate compared to traditional OOP languages. This makes TypeScript classes more productive while maintaining OOP principles.
Example 9: Inheritance and Method Overriding
Classes can extend other classes to inherit properties and methods. Subclasses can override parent methods while maintaining type safety.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
A["Base Class<br/>Animal"] --> B["Derived Class<br/>Dog"]
A --> C["Derived Class<br/>Cat"]
B --> D["Inherits Properties<br/>& Methods"]
C --> D
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#DE8F05,stroke:#000,color:#fff
style C fill:#029E73,stroke:#000,color:#fff
style D fill:#CC78BC,stroke:#000,color:#fff
Code:
// BASE CLASS
class Animal {
// => Parent class
constructor(public name: string) {} // => Public property via constructor param
makeSound(): void {
// => Method to override in subclasses
console.log("Some generic sound");
}
move(): void {
// => Method inherited by all subclasses
console.log(`${this.name} is moving`);
}
}
// DERIVED CLASS
class Dog extends Animal {
// => Dog inherits from Animal
// => Gets name property and move() method
constructor(
name: string,
public breed: string,
) {
// => Additional property
super(name); // => Call parent constructor
// => MUST call super() before using 'this'
}
makeSound(): void {
// => Override parent method
console.log("Woof! Woof!"); // => Dog-specific implementation
}
fetch(): void {
// => Dog-specific method
console.log(`${this.name} is fetching`);
}
}
const dog = new Dog("Buddy", "Golden Retriever"); // => Create Dog instance
dog.makeSound(); // => Output: Woof! Woof! (overridden method)
dog.move(); // => Output: Buddy is moving (inherited method)
dog.fetch(); // => Output: Buddy is fetching (Dog method)
// ANOTHER DERIVED CLASS
class Cat extends Animal {
// => Cat also inherits from Animal
makeSound(): void {
// => Override with Cat implementation
console.log("Meow!");
}
}
const cat = new Cat("Whiskers"); // => Create Cat instance
cat.makeSound(); // => Output: Meow! (Cat's override)
cat.move(); // => Output: Whiskers is moving (inherited)
// POLYMORPHISM
const animals: Animal[] = [dog, cat]; // => Array of Animal (base type)
// => Can hold Dog and Cat instances
animals.forEach((animal) => {
// => Iterate over animals
animal.makeSound(); // => Calls appropriate override
}); // => Output: Woof! Woof!
// => Output: Meow!
Key Takeaway: Use extends to inherit from a base class. Call super() in the constructor before accessing this. Override methods by redefining them in the subclass. Polymorphism allows treating subclasses as base class instances.
Why It Matters: Inheritance enables code reuse and polymorphic designs. Class components can extend base components. Middleware classes can extend base middleware. ORM models can extend base model classes. However, TypeScript and modern JavaScript increasingly favor composition over inheritance—interfaces and mixins provide more flexibility than deep inheritance hierarchies. Prefer shallow inheritance (1-2 levels) over deep chains that become brittle.
Example 10: Abstract Classes
Abstract classes define partial implementations that subclasses must complete. They cannot be instantiated directly and use abstract methods as contracts.
Code:
// ABSTRACT CLASS
abstract class Shape {
// => Cannot instantiate directly
// => new Shape() would be ERROR
constructor(public color: string) {} // => Constructor available to subclasses
abstract area(): number; // => Abstract method (no implementation)
// => Subclasses MUST implement this
describe(): void {
// => Concrete method (has implementation)
console.log(`A ${this.color} shape with area ${this.area()}`);
// => Calls abstract area() method
}
}
// CONCRETE SUBCLASS
class Circle extends Shape {
// => Must implement all abstract methods
constructor(
color: string,
public radius: number,
) {
super(color); // => Call parent constructor
}
area(): number {
// => Implement required abstract method
return Math.PI * this.radius ** 2; // => Returns circle area
}
}
class Rectangle extends Shape {
// => Another concrete subclass
constructor(
color: string,
public width: number,
public height: number,
) {
super(color);
}
area(): number {
// => Implement required abstract method
return this.width * this.height; // => Returns rectangle area
}
}
const circle = new Circle("red", 5); // => Create Circle instance
console.log(circle.area()); // => Output: 78.53981633974483
circle.describe(); // => Output: A red shape with area 78.53981633974483
const rectangle = new Rectangle("blue", 10, 20); // => Create Rectangle instance
console.log(rectangle.area()); // => Output: 200
rectangle.describe(); // => Output: A blue shape with area 200
// ARRAY OF ABSTRACT TYPE
const shapes: Shape[] = [circle, rectangle]; // => Array of abstract type
// => Holds concrete subclass instances
shapes.forEach((shape) => {
// => Polymorphic iteration
shape.describe(); // => Calls describe() on each shape
}); // => Output: A red shape with area 78.53...
// => Output: A blue shape with area 200
Key Takeaway: Abstract classes use abstract keyword and cannot be instantiated. Abstract methods have no implementation and must be implemented by concrete subclasses. Mix abstract and concrete methods for partial implementation.
Why It Matters: Abstract classes enforce contracts across subclasses while providing shared implementation. Unlike interfaces (which only define structure), abstract classes can include working code that subclasses inherit. ORM frameworks use abstract Model classes with concrete save() methods and abstract validate() methods. Game engines use abstract GameObject with concrete update() and abstract render(). However, many TypeScript developers prefer interfaces over abstract classes because interfaces support multiple inheritance and are more flexible.
Example 11: Literal Types and Type Narrowing
Literal types restrict values to specific literals. Type narrowing uses control flow to refine union types to more specific types.
Code:
// STRING LITERAL TYPE
type Direction = "north" | "south" | "east" | "west"; // => Only these 4 values allowed
function move(direction: Direction): void {
// => Parameter must be one of 4 literals
console.log(`Moving ${direction}`);
}
move("north"); // => Output: Moving north
// move("up"); // => ERROR: "up" not in Direction type
// NUMERIC LITERAL TYPE
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6; // => Only integers 1-6 allowed
function rollDice(): DiceRoll {
// => Must return 1-6
return (Math.floor(Math.random() * 6) + 1) as DiceRoll; // => Type assertion needed
// => Math.random() not typed as DiceRoll
}
console.log(rollDice()); // => Output: (random 1-6)
// TYPE NARROWING WITH typeof
function printValue(value: string | number): void {
// => Union type parameter
if (typeof value === "string") {
// => Type guard: narrows to string
console.log(value.toUpperCase()); // => value is string here
// => toUpperCase() available
} else {
// => Type guard: narrows to number
console.log(value.toFixed(2)); // => value is number here
// => toFixed() available
}
}
printValue("hello"); // => Output: HELLO
printValue(42.567); // => Output: 42.57
// TYPE NARROWING WITH instanceof
class Dog {
bark(): void {
console.log("Woof!");
}
}
class Cat {
meow(): void {
console.log("Meow!");
}
}
function makeSound(animal: Dog | Cat): void {
// => Union of class types
if (animal instanceof Dog) {
// => instanceof narrows to Dog
animal.bark(); // => bark() available (Dog method)
} else {
// => Must be Cat (exhaustive check)
animal.meow(); // => meow() available (Cat method)
}
}
makeSound(new Dog()); // => Output: Woof!
makeSound(new Cat()); // => Output: Meow!
// TYPE NARROWING WITH 'in' OPERATOR
interface Bird {
fly(): void;
layEggs(): void;
}
interface Fish {
swim(): void;
layEggs(): void;
}
function move2(animal: Bird | Fish): void {
// => Both have layEggs, different movement
if ("fly" in animal) {
// => Check if 'fly' property exists
// => Narrows to Bird
animal.fly(); // => fly() available (Bird method)
} else {
// => Must be Fish
animal.swim(); // => swim() available (Fish method)
}
}Key Takeaway: Literal types restrict variables to specific values. Type narrowing uses typeof, instanceof, and in checks to refine union types. TypeScript’s control flow analysis automatically narrows types based on conditional checks.
Why It Matters: Literal types create compile-time enums without runtime overhead. Action types can use literal unions: type Action = { type: "INCREMENT" } | { type: "DECREMENT" }. HTTP methods use literals: type Method = "GET" | "POST" | "PUT" | "DELETE". Type narrowing eliminates defensive programming—no need for runtime type checks when TypeScript proves types statically. This pattern is fundamental to discriminated unions in advanced TypeScript.
Example 12: Intersection Types
Intersection types (&) combine multiple types into one. A value must satisfy all combined types simultaneously.
Code:
// INTERSECTION OF INTERFACES
interface Loggable {
// => Interface with logging capability
log(): void;
}
interface Serializable {
// => Interface with serialization
serialize(): string;
}
type LoggableSerializable = Loggable & Serializable; // => Combines both interfaces
// => Must have log() AND serialize()
class User implements LoggableSerializable {
// => Class must implement both
constructor(
public name: string,
public email: string,
) {}
log(): void {
// => Implement Loggable.log()
console.log(`User: ${this.name}`);
}
serialize(): string {
// => Implement Serializable.serialize()
return JSON.stringify({ name: this.name, email: this.email });
}
}
const user = new User("Alice", "alice@example.com");
user.log(); // => Output: User: Alice
console.log(user.serialize()); // => Output: {"name":"Alice","email":"alice@example.com"}
// INTERSECTION OF TYPE ALIASES
type Point2D = { x: number; y: number }; // => 2D coordinates
type Label = { label: string }; // => Label property
type LabeledPoint = Point2D & Label; // => Combines both types
// => Must have x, y, AND label
const point: LabeledPoint = {
// => Object satisfies intersection
x: 10, // => From Point2D
y: 20, // => From Point2D
label: "Origin", // => From Label
};
console.log(point); // => Output: { x: 10, y: 20, label: 'Origin' }
// INTERSECTION WITH FUNCTION TYPES
type Logger = () => void; // => Function type (no params, void return)
type Formatter = (text: string) => string; // => Function type with params
type LoggerFormatter = Logger & Formatter; // => Intersection of function types
// => Function must match BOTH signatures
// => Practically impossible (conflicting signatures)
// PRACTICAL INTERSECTION - EXTENDING TYPES
type Entity = {
// => Base entity type
id: number;
createdAt: Date;
};
type Nameable = {
// => Adds name property
name: string;
};
type User2 = Entity & Nameable; // => Combines Entity + Nameable
// => Has id, createdAt, AND name
const user2: User2 = {
// => Object satisfies User2
id: 1, // => From Entity
createdAt: new Date(), // => From Entity
name: "Bob", // => From Nameable
};
console.log(user2.name); // => Output: Bob
Key Takeaway: Intersection types (A & B) combine multiple types—the result must satisfy all types simultaneously. Use intersections to extend types with additional properties or combine mixins.
Why It Matters: Intersection types enable mixin patterns without inheritance. Higher-Order Components can use intersections to add props: type EnhancedProps = BaseProps & WithAuth. Connected components can combine OwnProps & StateProps & DispatchProps. Utility type composition uses intersections: type ReadonlyPartial<T> = Readonly<T> & Partial<T>. Unlike union types (which are “either/or”), intersections are “both/and”, enabling flexible type composition.
Example 13: Type Guards with User-Defined Functions
Custom type guard functions use is keyword to narrow types. They return booleans that TypeScript uses for control flow narrowing.
Code:
// INTERFACE FOR TYPE GUARD EXAMPLE
interface Cat {
// => Cat interface
name: string;
meow(): void;
}
interface Dog {
// => Dog interface
name: string;
bark(): void;
}
// USER-DEFINED TYPE GUARD
function isCat(animal: Cat | Dog): animal is Cat {
// => Type predicate: 'animal is Cat'
// => Return type tells TypeScript about narrowing
return (animal as Cat).meow !== undefined; // => Check if meow method exists
// => Type assertion needed to access meow
}
function makeSound(animal: Cat | Dog): void {
// => Union type parameter
if (isCat(animal)) {
// => User-defined type guard
// => Inside block, TypeScript knows animal is Cat
animal.meow(); // => meow() available (Cat method)
} else {
// => TypeScript knows animal is Dog
animal.bark(); // => bark() available (Dog method)
}
}
const cat: Cat = {
// => Create Cat object
name: "Whiskers",
meow() {
console.log("Meow!");
},
};
const dog: Dog = {
// => Create Dog object
name: "Buddy",
bark() {
console.log("Woof!");
},
};
makeSound(cat); // => Output: Meow!
makeSound(dog); // => Output: Woof!
// TYPE GUARD FOR PRIMITIVES
function isString(value: unknown): value is string {
// => unknown type (safe any)
// => Narrows to string
return typeof value === "string"; // => Runtime check
}
function processValue(value: unknown): void {
// => unknown type parameter
if (isString(value)) {
// => Type guard narrows to string
console.log(value.toUpperCase()); // => toUpperCase() available
} else {
console.log("Not a string");
}
}
processValue("hello"); // => Output: HELLO
processValue(42); // => Output: Not a string
// TYPE GUARD FOR ARRAY
function isStringArray(value: unknown): value is string[] {
// => Narrows to string[]
return Array.isArray(value) && value.every((item) => typeof item === "string");
// => Check if array AND all elements are strings
}
function printStrings(value: unknown): void {
if (isStringArray(value)) {
// => Type guard narrows to string[]
value.forEach((str) => console.log(str.toUpperCase())); // => str is string
}
}
printStrings(["a", "b", "c"]); // => Output: A B C
Key Takeaway: User-defined type guards use parameter is Type return type syntax. The function’s boolean return tells TypeScript to narrow the type in conditional blocks. Use unknown type for maximum type safety when the input type is truly unknown.
Why It Matters: Type guards eliminate unsafe type assertions throughout codebases. API response parsing uses guards like isUser(data) before accessing user properties. Event handling uses isMouseEvent(event) to safely access clientX/clientY. Form validation uses isValidEmail(value) to narrow from unknown inputs. This pattern makes TypeScript’s control flow analysis powerful—the compiler proves types statically, preventing runtime errors from invalid data.
Example 14: readonly Properties and Readonly Utility Type
The readonly modifier prevents property reassignment. The Readonly<T> utility type makes all properties readonly.
Code:
// READONLY PROPERTY
interface User {
// => Interface with readonly property
readonly id: number; // => id cannot be reassigned after initialization
name: string; // => name is mutable
}
const user: User = {
// => Create User object
id: 1, // => id initialized
name: "Alice", // => name initialized
};
user.name = "Bob"; // => ALLOWED: name is mutable
console.log(user.name); // => Output: Bob
// user.id = 2; // => ERROR: Cannot assign to readonly property
// READONLY ARRAY
const numbers: readonly number[] = [1, 2, 3]; // => Readonly array
console.log(numbers[0]); // => Output: 1
// numbers.push(4); // => ERROR: push doesn't exist on readonly array
// numbers[0] = 10; // => ERROR: Cannot assign to index
// READONLY<T> UTILITY TYPE
interface Point {
// => Mutable interface
x: number;
y: number;
}
const point: Readonly<Point> = {
// => Readonly<T> makes all properties readonly
x: 10, // => x initialized
y: 20, // => y initialized
};
console.log(point.x); // => Output: 10
// point.x = 15; // => ERROR: Cannot assign to readonly property
// point.y = 25; // => ERROR: Cannot assign to readonly property
// READONLY WITH ARRAYS AND OBJECTS
interface Config {
database: {
host: string;
port: number;
};
features: string[];
}
const config: Readonly<Config> = {
// => Top-level readonly
database: { host: "localhost", port: 5432 },
features: ["auth", "logging"],
};
// config.database = { host: "prod", port: 5432 }; // => ERROR: Cannot reassign database
// However, nested properties are still mutable:
config.database.host = "production"; // => ALLOWED: Readonly is shallow
config.features.push("metrics"); // => ALLOWED: Array methods still work
console.log(config.database.host); // => Output: production
console.log(config.features); // => Output: ["auth", "logging", "metrics"]
// DEEP READONLY (UTILITY TYPE)
type DeepReadonly<T> = {
// => Recursive readonly type
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
}; // => Makes all nested properties readonly
const deepConfig: DeepReadonly<Config> = {
database: { host: "localhost", port: 5432 },
features: ["auth"],
};
// deepConfig.database.host = "prod"; // => ERROR: Cannot assign (deep readonly)
Key Takeaway: Use readonly modifier for individual properties. Use Readonly<T> utility type to make all properties readonly. Note that Readonly<T> is shallow—nested objects remain mutable unless using recursive types.
Why It Matters: Immutability prevents accidental mutations that cause bugs in shared data structures. React props are readonly to prevent components from modifying parent state. Redux state is readonly to enforce unidirectional data flow. Configuration objects use readonly to prevent runtime modification. However, TypeScript’s readonly is compile-time only—it doesn’t prevent mutations in JavaScript runtime. For true immutability, use libraries like Immer or enforce with runtime validation.
Example 15: Optional Chaining and Nullish Coalescing
Optional chaining (?.) safely accesses nested properties that might be null/undefined. Nullish coalescing (??) provides fallback values for null/undefined (but not falsy values like 0 or “”).
Code:
// OPTIONAL CHAINING WITH OBJECTS
interface User {
// => User interface with optional address
name: string;
address?: {
// => Optional nested object
street?: string;
city?: string;
};
}
const user1: User = {
// => User without address
name: "Alice",
};
const user2: User = {
// => User with partial address
name: "Bob",
address: {
city: "New York", // => street is undefined
},
};
// SAFE PROPERTY ACCESS
console.log(user1.address?.city); // => Output: undefined (address is undefined)
// => Optional chaining prevents error
console.log(user2.address?.city); // => Output: New York
console.log(user2.address?.street); // => Output: undefined (street is undefined)
// WITHOUT OPTIONAL CHAINING (OLD WAY)
// console.log(user1.address.city); // => ERROR: Cannot read property 'city' of undefined
// OPTIONAL CHAINING WITH METHODS
interface Calculator {
add?: (a: number, b: number) => number; // => Optional method
}
const calc1: Calculator = {
// => Calculator with add method
add: (a, b) => a + b,
};
const calc2: Calculator = {}; // => Calculator without add method
console.log(calc1.add?.(5, 3)); // => Output: 8 (method exists)
console.log(calc2.add?.(5, 3)); // => Output: undefined (method doesn't exist)
// => No error thrown
// OPTIONAL CHAINING WITH ARRAYS
interface Response {
data?: string[]; // => Optional array
}
const response1: Response = {
// => Response with data
data: ["a", "b", "c"],
};
const response2: Response = {}; // => Response without data
console.log(response1.data?.[0]); // => Output: a
console.log(response2.data?.[0]); // => Output: undefined (data doesn't exist)
// NULLISH COALESCING
const value1: string | null = null; // => null value
const value2: string | undefined = undefined; // => undefined value
const value3: string = ""; // => Empty string (falsy but not nullish)
const value4: number = 0; // => Zero (falsy but not nullish)
console.log(value1 ?? "default"); // => Output: default (null is nullish)
console.log(value2 ?? "default"); // => Output: default (undefined is nullish)
console.log(value3 ?? "default"); // => Output: "" (empty string NOT nullish)
console.log(value4 ?? "default"); // => Output: 0 (zero NOT nullish)
// COMPARISON WITH LOGICAL OR (||)
console.log(value3 || "default"); // => Output: default ("" is falsy)
console.log(value4 || "default"); // => Output: default (0 is falsy)
// => || treats all falsy values as missing
// => ?? only treats null/undefined as missing
// COMBINING OPTIONAL CHAINING AND NULLISH COALESCING
const user3: User = { name: "Charlie" };
const city = user3.address?.city ?? "Unknown"; // => Chain together safely
// => Provides fallback for undefined
console.log(city); // => Output: Unknown
Key Takeaway: Optional chaining (?.) safely accesses potentially null/undefined properties without errors. Nullish coalescing (??) provides defaults for null/undefined while preserving falsy values like 0 and “”. Combine them for safe nested access with fallbacks.
Why It Matters: Optional chaining eliminates defensive null checks that clutter codebases. Before ?., accessing user.address.city required: user && user.address && user.address.city. Now it’s just user?.address?.city. API responses use this heavily: response?.data?.items?.[0]?.name. The nullish coalescing operator fixes the logical OR (||) bug where 0 or "" are treated as missing values. This is critical for configuration: const port = config.port ?? 3000 keeps port 0 if explicitly set, while config.port || 3000 would replace 0 with 3000.
Example 16: Template Literal Types
Template literal types create string types from string literal patterns. They enable precise string validation at compile time.
Code:
// SIMPLE TEMPLATE LITERAL TYPE
type Greeting = `hello ${string}`; // => String must start with "hello "
// => Followed by any string
const validGreeting: Greeting = "hello world"; // => VALID: matches pattern
const invalidGreeting: Greeting = "hi world"; // => ERROR: doesn't start with "hello "
// TEMPLATE LITERAL WITH UNIONS
type Color = "red" | "blue" | "green"; // => Color union
type Quantity = "one" | "two" | "three"; // => Quantity union
type ColoredQuantity = `${Quantity} ${Color}`; // => Cartesian product of unions
// => Creates "one red" | "one blue" | ... | "three green"
const item: ColoredQuantity = "two blue"; // => VALID: matches pattern
console.log(item); // => Output: two blue
// CSS PROPERTY PATTERN
type CSSProperty = `${"margin" | "padding"}-${"top" | "bottom" | "left" | "right"}`;
// => Creates margin-top, margin-bottom, padding-left, etc.
const cssProperty: CSSProperty = "margin-top"; // => VALID
console.log(cssProperty); // => Output: margin-top
// EVENT HANDLER PATTERN
type EventName = "click" | "focus" | "blur"; // => Event types
type EventHandler = `on${Capitalize<EventName>}`; // => Creates onClick, onFocus, onBlur
// => Capitalize is built-in utility type
const handler: EventHandler = "onClick"; // => VALID: matches pattern
console.log(handler); // => Output: onClick
// API ENDPOINT PATTERN
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type Endpoint = `/api/${"users" | "products"}/${string}`; // => /api/users/* or /api/products/*
const apiUrl: Endpoint = "/api/users/123"; // => VALID
console.log(apiUrl); // => Output: /api/users/123
// COMBINING TEMPLATE LITERALS WITH MAPPED TYPES
type HTTPMethod = "GET" | "POST";
type Endpoint2 = "users" | "products";
type API = {
[K in `${HTTPMethod} /${Endpoint2}`]: () => void; // => Creates "GET /users", "POST /products", etc.
};
const api: API = {
// => Object with generated keys
"GET /users": () => console.log("Fetching users"),
"GET /products": () => console.log("Fetching products"),
"POST /users": () => console.log("Creating user"),
"POST /products": () => console.log("Creating product"),
};
api["GET /users"](); // => Output: Fetching users
Key Takeaway: Template literal types use backticks with ${} placeholders to create string patterns. Combine with unions to generate all permutations. Use built-in utilities like Capitalize for string transformations.
Why It Matters: Template literal types enable compile-time validation of string patterns that would otherwise require runtime checks. CSS-in-JS libraries use them for property names. GraphQL code generators create query patterns. API route typing uses them: type Route = \/${string}`. This catches typos in string constants at build time—no more“onClick”vs“onclick”` bugs. The pattern is especially powerful in libraries providing type-safe APIs over string-based configurations.
Example 17: keyof and typeof Operators
keyof creates a union of object property names. typeof infers the type of a value. Together they enable type-safe property access.
Code:
// keyof OPERATOR
interface User {
// => User interface
id: number;
name: string;
email: string;
}
type UserKeys = keyof User; // => Creates "id" | "name" | "email"
// => Union of property names
function getUserProperty(user: User, key: UserKeys): string | number {
// => Key must be valid property
return user[key]; // => Type-safe property access
}
const user: User = {
id: 1,
name: "Alice",
email: "alice@example.com",
};
console.log(getUserProperty(user, "name")); // => Output: Alice
// console.log(getUserProperty(user, "invalid")); // => ERROR: "invalid" not in UserKeys
// typeof OPERATOR
const config = {
// => JavaScript object
host: "localhost",
port: 3000,
debug: true,
};
type Config = typeof config; // => Infers type from value
// => { host: string; port: number; debug: boolean }
function loadConfig(cfg: Config): void {
// => Use inferred type
console.log(`${cfg.host}:${cfg.port}`);
}
loadConfig(config); // => Output: localhost:3000
// COMBINING keyof AND typeof
const endpoints = {
// => Endpoints object
users: "/api/users",
products: "/api/products",
orders: "/api/orders",
};
type Endpoint = keyof typeof endpoints; // => "users" | "products" | "orders"
// => typeof gets type, keyof gets keys
function fetchData(endpoint: Endpoint): string {
// => Type-safe endpoint parameter
return endpoints[endpoint]; // => Access with valid key
}
console.log(fetchData("users")); // => Output: /api/users
// console.log(fetchData("invalid")); // => ERROR: "invalid" not in Endpoint
// GENERIC PROPERTY ACCESS
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
// => Generic function
// => K must be key of T
// => Return type is T[K]
return obj[key]; // => Type-safe property access
}
interface Product {
id: number;
name: string;
price: number;
}
const product: Product = {
id: 1,
name: "Laptop",
price: 999,
};
const productName = getProperty(product, "name"); // => Type: string (inferred from Product["name"])
const productPrice = getProperty(product, "price"); // => Type: number (inferred from Product["price"])
console.log(productName); // => Output: Laptop
console.log(productPrice); // => Output: 999
// MAPPED TYPE WITH keyof
type ReadonlyUser = {
// => Mapped type
readonly [K in keyof User]: User[K]; // => Iterate over User keys
}; // => Makes all properties readonly
const readonlyUser: ReadonlyUser = {
id: 1,
name: "Bob",
email: "bob@example.com",
};
// readonlyUser.name = "Charlie"; // => ERROR: Cannot assign to readonly property
Key Takeaway: keyof T creates a union of property names from type T. typeof value infers the type from a JavaScript value. Use K extends keyof T in generics for type-safe property access.
Why It Matters: keyof enables type-safe property access without hardcoding property names. ORM libraries use keyof Model for type-safe queries. Form libraries use keyof FormData for field validation. typeof eliminates duplicate type definitions—define the JavaScript value once, infer the type. This is fundamental to TypeScript’s mapped types and generic constraints.
Example 18: Partial, Required, Pick, Omit Utility Types
TypeScript provides built-in utility types to transform existing types. These enable flexible type composition without duplication.
Code:
// BASE INTERFACE
interface User {
// => Base User type
id: number;
name: string;
email: string;
age: number;
address: string;
}
// PARTIAL<T> - MAKES ALL PROPERTIES OPTIONAL
type PartialUser = Partial<User>; // => All properties become optional
// => { id?: number; name?: string; ... }
function updateUser(id: number, updates: PartialUser): void {
// => Can pass any subset
console.log(`Updating user ${id}`, updates);
}
updateUser(1, { name: "Alice" }); // => VALID: only name provided
updateUser(2, { age: 30, email: "bob@example.com" }); // => VALID: partial update
// REQUIRED<T> - MAKES ALL PROPERTIES REQUIRED
interface Config {
// => Config with optional properties
host?: string;
port?: number;
debug?: boolean;
}
type RequiredConfig = Required<Config>; // => All properties become required
// => { host: string; port: number; debug: boolean }
const config: RequiredConfig = {
// => Must provide all properties
host: "localhost",
port: 3000,
debug: true,
};
console.log(config.host); // => Output: localhost
// PICK<T, K> - SELECT SPECIFIC PROPERTIES
type UserPreview = Pick<User, "id" | "name">; // => Only id and name
// => { id: number; name: string }
const preview: UserPreview = {
// => Only picked properties allowed
id: 1,
name: "Alice",
};
console.log(preview); // => Output: { id: 1, name: 'Alice' }
// OMIT<T, K> - EXCLUDE SPECIFIC PROPERTIES
type UserWithoutEmail = Omit<User, "email">; // => All except email
// => { id, name, age, address }
const userWithoutEmail: UserWithoutEmail = {
id: 1,
name: "Bob",
age: 25,
address: "123 Main St",
};
console.log(userWithoutEmail); // => Output: { id: 1, name: 'Bob', age: 25, address: '123 Main St' }
// COMBINING UTILITY TYPES
type UserUpdate = Partial<Omit<User, "id">>; // => All properties optional except id removed
// => { name?: string; email?: string; age?: number; address?: string }
function patchUser(id: number, data: UserUpdate): void {
console.log(`Patching user ${id}`, data);
}
patchUser(1, { name: "Charlie" }); // => VALID: partial update without id
// PICK WITH UNION
type ContactInfo = Pick<User, "email" | "address">; // => Email and address only
const contact: ContactInfo = {
email: "diana@example.com",
address: "456 Elm St",
};
console.log(contact); // => Output: { email: 'diana@example.com', address: '456 Elm St' }
Key Takeaway: Partial<T> makes all properties optional. Required<T> makes all required. Pick<T, K> selects specific properties. Omit<T, K> excludes properties. Combine them for complex transformations without manual type definitions.
Why It Matters: Utility types eliminate boilerplate and prevent drift between related types. API request/response types use Partial for updates. Database models use Omit<User, "password"> for public data. Form state uses Required<FormData> after validation. These utilities are the foundation of type-safe APIs—they adapt existing types to specific contexts without duplication.
Example 19: Record Utility Type
Record<K, V> creates an object type with keys K and values V. It’s useful for creating dictionaries and maps with type constraints.
Code:
// BASIC RECORD TYPE
type UserRole = "admin" | "user" | "guest"; // => Literal type union
type RolePermissions = Record<UserRole, string[]>; // => Object with UserRole keys, string[] values
// => { admin: string[], user: string[], guest: string[] }
const permissions: RolePermissions = {
// => Must provide all roles
admin: ["create", "read", "update", "delete"], // => Admin permissions
user: ["create", "read", "update"], // => User permissions
guest: ["read"], // => Guest permissions (limited)
};
console.log(permissions.admin); // => Output: ["create", "read", "update", "delete"]
// RECORD WITH NUMERIC KEYS
type StatusCode = 200 | 404 | 500; // => HTTP status codes
type StatusMessages = Record<StatusCode, string>; // => Map codes to messages
const messages: StatusMessages = {
// => All codes must be provided
200: "OK",
404: "Not Found",
500: "Internal Server Error",
};
console.log(messages[404]); // => Output: Not Found
// RECORD FOR CONFIGURATION
type Environment = "development" | "staging" | "production";
interface DatabaseConfig {
// => Database connection config
host: string;
port: number;
}
type EnvironmentConfig = Record<Environment, DatabaseConfig>; // => Config per environment
const dbConfig: EnvironmentConfig = {
development: {
host: "localhost",
port: 5432,
},
staging: {
host: "staging.db.example.com",
port: 5432,
},
production: {
host: "prod.db.example.com",
port: 5432,
},
};
console.log(dbConfig.production.host); // => Output: prod.db.example.com
// RECORD WITH STRING INDEX
type Dictionary = Record<string, number>; // => Any string key, number value
// => Equivalent to { [key: string]: number }
const scores: Dictionary = {
// => Can add any string keys
alice: 95,
bob: 87,
charlie: 92,
};
console.log(scores.alice); // => Output: 95
scores.diana = 88; // => VALID: add new key
console.log(scores.diana); // => Output: 88
// RECORD VS INDEX SIGNATURE
interface ScoresIndex {
// => Index signature approach
[key: string]: number;
}
const scoresIndex: ScoresIndex = {
// => Same behavior as Record<string, number>
alice: 95,
bob: 87,
};
// Both approaches equivalent for dynamic keys
// Record<K, V> is more concise for specific key types
Key Takeaway: Record<K, V> creates object types with specific key and value types. Use it for dictionaries, maps, and configuration objects. It’s more concise than index signatures for specific key sets.
Why It Matters: Record is the standard pattern for key-value data structures with type safety. State management can use Record<string, User> for normalized entities. Configuration systems can use Record<Environment, Config>. Translation files can use Record<string, string> for i18n keys. The type ensures all expected keys exist (when using literal unions) or validates value types for dynamic keys.
Example 20: Type Assertions vs Type Guards
Type assertions (as) tell the compiler to trust you about a type. Type guards prove types through runtime checks. Guards are safer than assertions.
Code:
// TYPE ASSERTION (UNSAFE)
function getValueAssertion(): any {
// => Returns any (no type safety)
return "Hello"; // => Actually returns string
}
const value1 = getValueAssertion() as string; // => Assertion: trust me it's string
// => No runtime check
console.log(value1.toUpperCase()); // => Output: HELLO (works because actually string)
const value2 = getValueAssertion() as number; // => Assertion: trust me it's number
// => DANGER: actually string!
// console.log(value2.toFixed(2)); // => ERROR at runtime: toFixed not on string
// TYPE GUARD (SAFE)
function isString(value: unknown): value is string {
// => Type predicate
return typeof value === "string"; // => Runtime check
}
function getValueGuard(): unknown {
// => Returns unknown (forces checking)
return "Hello";
}
const value3 = getValueGuard(); // => value3 type: unknown
if (isString(value3)) {
// => Type guard narrows to string
console.log(value3.toUpperCase()); // => Output: HELLO (safe)
} else {
console.log("Not a string");
}
// ASSERTION WITH DOM ELEMENTS (COMMON USE CASE)
const input1 = document.getElementById("username") as HTMLInputElement; // => Assertion
// => DANGER: might be null!
// input1.value = "Alice"; // => Crashes if element doesn't exist
// SAFER APPROACH WITH GUARD
const input2 = document.getElementById("username"); // => Type: HTMLElement | null
if (input2 instanceof HTMLInputElement) {
// => Type guard with instanceof
input2.value = "Alice"; // => Safe: null checked, type narrowed
console.log(input2.value); // => Output: Alice
}
// ASSERTION FOR COMPLEX TYPES
interface User {
id: number;
name: string;
}
function getUser(): unknown {
// => Returns unknown
return { id: 1, name: "Bob" }; // => Actually returns User-like object
}
// UNSAFE ASSERTION
const user1 = getUser() as User; // => Assertion: no validation
console.log(user1.name); // => Output: Bob (works by luck)
// SAFE TYPE GUARD
function isUser(value: unknown): value is User {
// => Type guard for User
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value &&
typeof (value as User).id === "number" &&
typeof (value as User).name === "string"
); // => Validates all required properties
}
const maybeUser = getUser(); // => Type: unknown
if (isUser(maybeUser)) {
// => Type guard validates at runtime
console.log(maybeUser.name); // => Output: Bob (safe)
} else {
console.log("Invalid user");
}
// WHEN ASSERTIONS ARE ACCEPTABLE
interface ApiResponse {
// => Known API contract
data: string;
}
const response = JSON.parse('{"data": "value"}') as ApiResponse; // => JSON.parse returns any
// => Assertion acceptable when API contract known
console.log(response.data); // => Output: value
Key Takeaway: Type assertions bypass type checking—use sparingly and only when you’re certain about the type. Type guards prove types through runtime checks—they’re safer. Prefer guards for untrusted data and assertions for known contracts.
Why It Matters: Type assertions are necessary evils when TypeScript’s type system can’t infer correctly (DOM APIs, JSON parsing, migration from JavaScript). However, they disable type safety—the compiler trusts you blindly. Type guards provide both runtime safety and compile-time narrowing. Production code should minimize assertions through better typing (generics, guards, proper types). The distinction is critical for API integration where data shapes are uncertain.
Example 21: String, Number, Boolean Object Wrappers
JavaScript has both primitive types (string, number, boolean) and object wrapper types (String, Number, Boolean). TypeScript distinguishes them—almost always use primitives.
Code:
// PRIMITIVE TYPES (PREFERRED)
let str: string = "hello"; // => Primitive string (lowercase)
let num: number = 42; // => Primitive number
let bool: boolean = true; // => Primitive boolean
console.log(typeof str); // => Output: string (primitive)
console.log(typeof num); // => Output: number (primitive)
console.log(typeof bool); // => Output: boolean (primitive)
// OBJECT WRAPPER TYPES (AVOID)
let strObj: String = new String("hello"); // => Object wrapper (uppercase)
// => Type: String (object, not primitive)
let numObj: Number = new Number(42); // => Object wrapper for number
let boolObj: Boolean = new Boolean(true); // => Object wrapper for boolean
console.log(typeof strObj); // => Output: object (NOT string!)
console.log(typeof numObj); // => Output: object (NOT number!)
console.log(typeof boolObj); // => Output: object (NOT boolean!)
// INCOMPATIBILITY
// let str2: string = strObj; // => ERROR: Type 'String' not assignable to 'string'
// => Object wrapper incompatible with primitive
// PRIMITIVE TO WRAPPER (AUTO-BOXING)
let primitive = "hello"; // => Primitive string
let length = primitive.length; // => Access .length property
// => JavaScript auto-boxes to String object temporarily
// => Then discards wrapper
console.log(length); // => Output: 5
// WRAPPER TO PRIMITIVE (EXPLICIT CONVERSION)
let wrapper = new String("world"); // => String object wrapper
let primitiveFromWrapper = wrapper.valueOf(); // => Extract primitive value
// => Returns string primitive
console.log(typeof primitiveFromWrapper); // => Output: string (primitive)
// COMPARISON BEHAVIOR
let prim1 = "test"; // => Primitive string
let prim2 = "test"; // => Another primitive string
console.log(prim1 === prim2); // => Output: true (value comparison)
let obj1 = new String("test"); // => String object
let obj2 = new String("test"); // => Another String object
console.log(obj1 === obj2); // => Output: false (reference comparison)
console.log(obj1.valueOf() === obj2.valueOf()); // => Output: true (compare primitives)
// BEST PRACTICE: ALWAYS USE PRIMITIVES
function greet(name: string): string {
// => Use lowercase string
return `Hello, ${name}`;
}
console.log(greet("Alice")); // => Output: Hello, Alice
// AVOID OBJECT WRAPPERS
// function badGreet(name: String): String { // => AVOID: uppercase String
// return new String(`Hello, ${name}`); // => Returns object, not primitive
// }
Key Takeaway: Use primitive types (string, number, boolean) not object wrappers (String, Number, Boolean). Primitives are faster, use less memory, and are compatible with standard library. Object wrappers exist for compatibility but should be avoided.
Why It Matters: The primitive vs object distinction causes subtle bugs. Object wrappers fail equality checks (new String("a") !== new String("a")), consume more memory, and break type compatibility. TypeScript’s type checker enforces primitives by default, preventing this footgun. However, the distinction confuses beginners coming from Java/C# where all types are objects. Always use lowercase type names in TypeScript.
Example 22: never Type and Exhaustiveness Checking
The never type represents values that never occur. It’s used for functions that never return and for exhaustive type checking in switch statements.
Code:
// FUNCTION THAT NEVER RETURNS
function throwError(message: string): never {
// => Return type: never
// => Function never returns normally
throw new Error(message); // => Throws exception
// => Execution never reaches end
}
// Function using never
function processValue(value: string | number): string {
if (typeof value === "string") {
return value.toUpperCase(); // => Returns string
} else if (typeof value === "number") {
return value.toFixed(2); // => Returns string
} else {
throwError("Invalid type"); // => never type
// => TypeScript knows this never returns
}
}
// INFINITE LOOP (NEVER RETURNS)
function infiniteLoop(): never {
// => Return type: never
while (true) {
// => Infinite loop
console.log("Running forever");
} // => Never exits
}
// EXHAUSTIVENESS CHECKING
type Shape = Circle | Square | Triangle; // => Union of shapes
interface Circle {
kind: "circle";
radius: number;
}
interface Square {
kind: "square";
sideLength: number;
}
interface Triangle {
kind: "triangle";
base: number;
height: number;
}
function getArea(shape: Shape): number {
// => Calculate area by shape
switch (
shape.kind // => Discriminated union pattern
) {
case "circle":
return Math.PI * shape.radius ** 2; // => Circle area
case "square":
return shape.sideLength ** 2; // => Square area
case "triangle":
return (shape.base * shape.height) / 2; // => Triangle area
default:
const exhaustiveCheck: never = shape; // => Ensures all cases handled
// => If new shape added, this errors
return exhaustiveCheck; // => Never reached
}
}
const circle: Circle = { kind: "circle", radius: 5 };
console.log(getArea(circle)); // => Output: 78.53981633974483
// IF WE ADD A NEW SHAPE WITHOUT UPDATING SWITCH:
// type Shape = Circle | Square | Triangle | Rectangle; // => Add Rectangle
// => The 'default' case would error: Type 'Rectangle' not assignable to 'never'
// => Compiler forces us to handle new case
// NEVER AS BOTTOM TYPE
let neverValue: never; // => never is bottom type
// => No value can be assigned
// neverValue = 5; // => ERROR: number not assignable to never
// neverValue = "text"; // => ERROR: string not assignable to never
// However, never is assignable to everything
let str: string = throwError("oops"); // => never assignable to string
// => Function never returns anyway
// UNION WITH never
type Example1 = string | never; // => Simplifies to string
// => never removed from union
type Example2 = number | never | boolean; // => Simplifies to number | boolean
Key Takeaway: never represents impossible values—functions that never return (throw or infinite loop) or unreachable code. Use it in default cases for exhaustiveness checking in discriminated unions.
Why It Matters: Exhaustiveness checking prevents bugs when adding variants to unions. If you add a new shape type but forget to handle it in getArea, the compiler errors immediately. This pattern is crucial for action reducers (handling all action types), API response handlers (handling all status codes), and state machines (handling all states). The never type makes impossible states unrepresentable, a core tenet of type-safe design.
Example 23: unknown Type (Type-Safe any)
The unknown type is a type-safe alternative to any. You can assign any value to unknown, but you must narrow the type before using it.
Code:
// any TYPE (UNSAFE)
let anyValue: any = "hello"; // => any disables type checking
anyValue = 42; // => Can assign anything
anyValue.toUpperCase(); // => No error (but crashes if number!)
anyValue.foo.bar.baz; // => No error (crashes at runtime!)
// unknown TYPE (SAFE)
let unknownValue: unknown = "hello"; // => unknown accepts any value
unknownValue = 42; // => Can assign anything
// unknownValue.toUpperCase(); // => ERROR: Object is of type 'unknown'
// => Must narrow type first
// TYPE NARROWING WITH unknown
if (typeof unknownValue === "string") {
// => Type guard narrows to string
console.log(unknownValue.toUpperCase()); // => Output: HELLO (when string)
} else if (typeof unknownValue === "number") {
// => Type guard narrows to number
console.log(unknownValue.toFixed(2)); // => toFixed available (number)
}
// FUNCTION ACCEPTING unknown
function processInput(input: unknown): string {
// => Safer than any
if (typeof input === "string") {
// => Must check type
return input.toUpperCase(); // => Type narrowed to string
} else if (typeof input === "number") {
return input.toString(); // => Type narrowed to number
} else {
return "Unknown type"; // => Fallback for other types
}
}
console.log(processInput("hello")); // => Output: HELLO
console.log(processInput(42)); // => Output: 42
console.log(processInput(true)); // => Output: Unknown type
// JSON PARSING (PRACTICAL USE CASE)
function parseJSON(json: string): unknown {
// => Returns unknown (don't trust input)
return JSON.parse(json); // => JSON.parse returns any
} // => Returning unknown forces callers to validate
const data = parseJSON('{"name": "Alice", "age": 30}'); // => Type: unknown
// Must validate before use
if (typeof data === "object" && data !== null && "name" in data && "age" in data) {
const obj = data as { name: string; age: number }; // => Type assertion after validation
console.log(obj.name); // => Output: Alice
}
// TYPE PREDICATE WITH unknown
function isUser(value: unknown): value is { id: number; name: string } {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value &&
typeof (value as any).id === "number" &&
typeof (value as any).name === "string"
);
}
function processUser(value: unknown): void {
if (isUser(value)) {
// => Type guard narrows to User shape
console.log(`User ID: ${value.id}, Name: ${value.name}`);
} else {
console.log("Not a user");
}
}
processUser({ id: 1, name: "Bob" }); // => Output: User ID: 1, Name: Bob
processUser("invalid"); // => Output: Not a user
// unknown vs any IN UNIONS
type Value1 = string | any; // => Simplifies to any (loses type safety)
type Value2 = string | unknown; // => Simplifies to unknown (maintains safety)
Key Takeaway: Use unknown instead of any when you don’t know the type. unknown requires type narrowing before use, preventing runtime errors. It’s the type-safe top type in TypeScript.
Why It Matters: unknown forces defensive programming for untrusted data. API responses, JSON parsing, and user input should use unknown to mandate validation. This prevents the silent bugs that any allows. Migration from JavaScript often starts with any everywhere; refactoring to unknown adds safety without breaking functionality. The pattern is: accept unknown, validate, narrow, then operate.
Example 24: void, null, undefined Differences
TypeScript distinguishes void (no return value), null (intentional absence), and undefined (uninitialized or missing). Understanding the differences prevents subtle bugs.
Code:
// void TYPE - FUNCTION RETURNS NOTHING
function logMessage(message: string): void {
// => Return type: void
console.log(message); // => Side effect (logging)
// => No return statement
}
logMessage("Hello"); // => Output: Hello
const result1 = logMessage("Test"); // => result1 type: void
console.log(result1); // => Output: undefined (void becomes undefined)
// void ALLOWS undefined RETURN
function doSomething(): void {
// => Return type: void
return undefined; // => ALLOWED: can return undefined
}
// But disallows other values
// function invalid(): void {
// return "text"; // => ERROR: Type 'string' not assignable to void
// }
// undefined TYPE - SPECIFICALLY UNINITIALIZED
let uninitializedValue: undefined; // => Type: undefined (no value assigned)
console.log(uninitializedValue); // => Output: undefined
function returnUndefined(): undefined {
// => Must return undefined
return undefined; // => Explicit undefined
}
// null TYPE - INTENTIONAL ABSENCE
let nullValue: null = null; // => Type: null (intentional no-value)
function findUser(id: number): User | null {
// => May return null (not found)
if (id === 1) {
return { id: 1, name: "Alice" }; // => User found
}
return null; // => Not found (null)
}
const user1 = findUser(1); // => Type: User | null
const user2 = findUser(999); // => Type: User | null
console.log(user2); // => Output: null
// UNION WITH null AND undefined
function getValue(): string | null | undefined {
// => Can return any of 3
const random = Math.random();
if (random < 0.33) {
return "value"; // => Returns string
} else if (random < 0.66) {
return null; // => Returns null (intentional absence)
} else {
return undefined; // => Returns undefined (no value)
}
}
// STRICTNULLCHECKS COMPILER OPTION
// With strictNullChecks enabled (recommended):
let str1: string = "hello"; // => string type
// str1 = null; // => ERROR: null not assignable to string
// str1 = undefined; // => ERROR: undefined not assignable to string
let str2: string | null = "hello"; // => Allows null
str2 = null; // => ALLOWED
let str3: string | undefined = "hello"; // => Allows undefined
str3 = undefined; // => ALLOWED
// OPTIONAL PARAMETERS (IMPLICITLY undefined)
function greet(name?: string): void {
// => name type: string | undefined
// => Optional params allow undefined
if (name) {
console.log(`Hello, ${name}`); // => name narrowed to string
} else {
console.log("Hello, stranger"); // => name is undefined
}
}
greet("Alice"); // => Output: Hello, Alice
greet(); // => Output: Hello, stranger (undefined)
// COMPARISON
console.log(null == undefined); // => Output: true (loose equality)
console.log(null === undefined); // => Output: false (strict equality)
console.log(typeof null); // => Output: object (JavaScript quirk!)
console.log(typeof undefined); // => Output: undefined
Key Takeaway: void is for functions with no return value (side effects only). undefined means uninitialized or missing. null means intentionally absent. Use strictNullChecks compiler option to enforce explicit null/undefined handling.
Why It Matters: The void/null/undefined distinction prevents bugs from missing values. Database queries return null for not-found (intentional). Optional parameters are undefined (omitted). Functions with side effects return void (no meaningful value). The strictNullChecks compiler option eliminates billion-dollar null reference errors by forcing explicit null checks. This is one of TypeScript’s killer features over JavaScript.
Example 25: Type Predicates for Arrays
Type predicates can filter arrays while narrowing element types. This enables type-safe filtering operations.
Code:
// FILTER WITHOUT TYPE PREDICATE
interface User {
id: number;
name: string;
}
interface Product {
id: number;
title: string;
}
const items: (User | Product)[] = [
// => Array of mixed types
{ id: 1, name: "Alice" }, // => User
{ id: 2, title: "Laptop" }, // => Product
{ id: 3, name: "Bob" }, // => User
{ id: 4, title: "Mouse" }, // => Product
];
// Without type predicate
const filtered1 = items.filter((item) => "name" in item); // => Filter users
console.log(filtered1); // => Output: [{id: 1, name: 'Alice'}, {id: 3, name: 'Bob'}]
// => Type: (User | Product)[] (not narrowed!)
// TYPE PREDICATE FOR FILTERING
function isUser(item: User | Product): item is User {
// => Type predicate
return "name" in item; // => Check for 'name' property
}
const users = items.filter(isUser); // => Type narrowed to User[]
// => Not (User | Product)[]!
console.log(users[0].name); // => Output: Alice (name available, type-safe)
// ANOTHER TYPE PREDICATE
function isProduct(item: User | Product): item is Product {
return "title" in item; // => Check for 'title' property
}
const products = items.filter(isProduct); // => Type narrowed to Product[]
console.log(products[0].title); // => Output: Laptop (title available)
// FILTERING null/undefined FROM ARRAYS
function isDefined<T>(value: T | null | undefined): value is T {
// => Generic type predicate
return value !== null && value !== undefined; // => Check for null/undefined
}
const maybeNumbers: (number | null | undefined)[] = [1, null, 2, undefined, 3];
const numbers = maybeNumbers.filter(isDefined); // => Type narrowed to number[]
// => null and undefined removed
console.log(numbers); // => Output: [1, 2, 3]
console.log(numbers[0].toFixed(2)); // => Output: 1.00 (type-safe)
// COMPLEX TYPE PREDICATE
interface Dog {
type: "dog";
bark(): void;
}
interface Cat {
type: "cat";
meow(): void;
}
type Animal = Dog | Cat;
const animals: Animal[] = [
{
type: "dog",
bark() {
console.log("Woof");
},
},
{
type: "cat",
meow() {
console.log("Meow");
},
},
{
type: "dog",
bark() {
console.log("Woof woof");
},
},
];
function isDog(animal: Animal): animal is Dog {
// => Type predicate for Dog
return animal.type === "dog"; // => Check discriminator property
}
const dogs = animals.filter(isDog); // => Type narrowed to Dog[]
dogs.forEach((dog) => dog.bark()); // => Output: Woof, Woof woof (type-safe)
// INLINE TYPE PREDICATE
const cats = animals.filter((animal): animal is Cat => animal.type === "cat");
// => Inline type predicate
// => Type narrowed to Cat[]
cats.forEach((cat) => cat.meow()); // => Output: Meow (type-safe)
Key Takeaway: Type predicates (value is Type) enable type-safe filtering. Use them to narrow array element types after filter operations. Generic type predicates handle null/undefined filtering universally.
Why It Matters: Array filtering without type predicates loses type information—items.filter(x => "name" in x) returns the same union type. Type predicates solve this by teaching TypeScript about the filter logic. This pattern is essential for discriminated unions, nullable arrays, and mixed-type collections. Redux selectors use this pattern: state.items.filter(isLoaded) returns LoadedItem[] not Item[].
Example 26: Discriminated Unions (Tagged Unions)
Discriminated unions use a common literal property (discriminator) to distinguish between union members. They enable exhaustive pattern matching.
Code:
// DISCRIMINATED UNION
interface Circle {
kind: "circle"; // => Discriminator (literal type)
radius: number;
}
interface Square {
kind: "square"; // => Discriminator
sideLength: number;
}
interface Triangle {
kind: "triangle"; // => Discriminator
base: number;
height: number;
}
type Shape = Circle | Square | Triangle; // => Union of shapes
// PATTERN MATCHING WITH DISCRIMINATOR
function getArea(shape: Shape): number {
// => Calculate area by shape
switch (
shape.kind // => Switch on discriminator
) {
case "circle":
// TypeScript knows shape is Circle here
return Math.PI * shape.radius ** 2; // => radius available
case "square":
// TypeScript knows shape is Square here
return shape.sideLength ** 2; // => sideLength available
case "triangle":
// TypeScript knows shape is Triangle here
return (shape.base * shape.height) / 2; // => base & height available
default:
const exhaustiveCheck: never = shape; // => Exhaustiveness check
throw new Error(`Unhandled shape: ${exhaustiveCheck}`);
}
}
const circle: Circle = { kind: "circle", radius: 5 };
const square: Square = { kind: "square", sideLength: 10 };
const triangle: Triangle = { kind: "triangle", base: 8, height: 6 };
console.log(getArea(circle)); // => Output: 78.53981633974483
console.log(getArea(square)); // => Output: 100
console.log(getArea(triangle)); // => Output: 24
// DISCRIMINATED UNION FOR API RESPONSES
interface SuccessResponse {
status: "success"; // => Discriminator
data: string[];
}
interface ErrorResponse {
status: "error"; // => Discriminator
errorMessage: string;
}
type ApiResponse = SuccessResponse | ErrorResponse;
function handleResponse(response: ApiResponse): void {
if (response.status === "success") {
// => Type narrowed to SuccessResponse
console.log("Data:", response.data.join(", ")); // => data available
} else {
// => Type narrowed to ErrorResponse
console.error("Error:", response.errorMessage); // => errorMessage available
}
}
handleResponse({ status: "success", data: ["a", "b", "c"] }); // => Output: Data: a, b, c
handleResponse({ status: "error", errorMessage: "Not found" }); // => Output: Error: Not found
// DISCRIMINATED UNION FOR STATE MACHINE
interface Idle {
state: "idle"; // => Discriminator
}
interface Loading {
state: "loading"; // => Discriminator
progress: number;
}
interface Success {
state: "success"; // => Discriminator
data: string;
}
interface Failure {
state: "failure"; // => Discriminator
error: string;
}
type AsyncState = Idle | Loading | Success | Failure;
function renderState(state: AsyncState): string {
switch (state.state) {
case "idle":
return "Waiting to start...";
case "loading":
return `Loading... ${state.progress}%`; // => progress available
case "success":
return `Success: ${state.data}`; // => data available
case "failure":
return `Error: ${state.error}`; // => error available
default:
const exhaustive: never = state;
return exhaustive;
}
}
console.log(renderState({ state: "idle" })); // => Output: Waiting to start...
console.log(renderState({ state: "loading", progress: 50 })); // => Output: Loading... 50%
console.log(renderState({ state: "success", data: "Complete" })); // => Output: Success: Complete
console.log(renderState({ state: "failure", error: "Network issue" })); // => Output: Error: Network issue
Key Takeaway: Discriminated unions use a common literal property to distinguish union members. Switch on the discriminator to narrow types automatically. Use never in the default case for exhaustiveness checking.
Why It Matters: Discriminated unions are the foundation of type-safe state management. Redux actions use type as discriminator. API responses use status. Async states use state. This pattern eliminates defensive programming—TypeScript proves all cases handled at compile time. Adding new variants triggers compiler errors in unhandled switches, preventing bugs from forgotten cases.
Example 27: Index Signatures and Mapped Types
Index signatures allow objects with dynamic property names. Mapped types transform existing types by iterating over properties.
Code:
// INDEX SIGNATURE
interface Dictionary {
// => Dictionary with string keys
[key: string]: number; // => Any string key maps to number value
}
const scores: Dictionary = {
// => Can add any string keys
alice: 95,
bob: 87,
charlie: 92,
};
console.log(scores.alice); // => Output: 95
scores.diana = 88; // => Add new key dynamically
console.log(scores.diana); // => Output: 88
// NUMERIC INDEX SIGNATURE
interface NumberDictionary {
[index: number]: string; // => Numeric keys map to string values
}
const names: NumberDictionary = {
0: "Alice",
1: "Bob",
2: "Charlie",
};
console.log(names[1]); // => Output: Bob
// MAPPED TYPE - READONLY
type Readonly<T> = {
// => Makes all properties readonly
readonly [P in keyof T]: T[P]; // => Iterate over T's keys
}; // => P is each property name
interface User {
id: number;
name: string;
}
type ReadonlyUser = Readonly<User>; // => All properties become readonly
// => { readonly id: number; readonly name: string }
const user: ReadonlyUser = {
id: 1,
name: "Alice",
};
// user.name = "Bob"; // => ERROR: Cannot assign to readonly property
// MAPPED TYPE - OPTIONAL
type Optional<T> = {
// => Makes all properties optional
[P in keyof T]?: T[P]; // => Add ? to each property
};
type OptionalUser = Optional<User>; // => { id?: number; name?: string }
const partialUser: OptionalUser = {
// => Can omit properties
name: "Charlie", // => id omitted
};
console.log(partialUser); // => Output: { name: 'Charlie' }
// MAPPED TYPE - NULLABLE
type Nullable<T> = {
// => Makes all properties nullable
[P in keyof T]: T[P] | null; // => Union with null
};
type NullableUser = Nullable<User>; // => { id: number | null; name: string | null }
const nullableUser: NullableUser = {
id: null, // => null allowed
name: "Diana",
};
console.log(nullableUser); // => Output: { id: null, name: 'Diana' }
// MAPPED TYPE WITH KEY TRANSFORMATION
type Getters<T> = {
// => Create getter methods
[P in keyof T as `get${Capitalize<string & P>}`]: () => T[P];
}; // => Transform property names to getters
type UserGetters = Getters<User>; // => { getId: () => number; getName: () => string }
const userGetters: UserGetters = {
getId: () => 1,
getName: () => "Eve",
};
console.log(userGetters.getId()); // => Output: 1
console.log(userGetters.getName()); // => Output: Eve
// COMBINED INDEX SIGNATURE AND KNOWN PROPERTIES
interface FlexibleConfig {
host: string; // => Required known property
port: number; // => Required known property
[key: string]: string | number; // => Additional dynamic properties
}
const config: FlexibleConfig = {
host: "localhost",
port: 3000,
debug: true, // => ERROR: boolean not assignable
timeout: 5000, // => ALLOWED: number matches signature
};Key Takeaway: Index signatures enable dynamic property names with type constraints. Mapped types iterate over property keys to transform types. Use in keyof to iterate and as for key transformations.
Why It Matters: Index signatures handle dynamic data structures (dictionaries, configuration objects, translation maps) while maintaining type safety. Mapped types power TypeScript’s utility types (Partial, Readonly, Pick) and enable generic type transformations. This pattern is fundamental to generic component props and normalized state shapes.
Example 28: Conditional Types
Conditional types select types based on conditions using extends keyword. They enable type-level programming for advanced type transformations.
Code:
// BASIC CONDITIONAL TYPE
type IsString<T> = T extends string ? true : false; // => If T extends string, return true type, else false
type Test1 = IsString<string>; // => Type: true
type Test2 = IsString<number>; // => Type: false
// CONDITIONAL TYPE WITH INFER
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
// => If T is function, infer return type R
// => Otherwise return never
function getString(): string {
return "hello";
}
function getNumber(): number {
return 42;
}
type StringReturn = ReturnType<typeof getString>; // => Type: string
type NumberReturn = ReturnType<typeof getNumber>; // => Type: number
// CONDITIONAL TYPE FOR UNWRAPPING
type Unwrap<T> = T extends Promise<infer U> ? U : T; // => Unwrap Promise type
// => If Promise<U>, return U
// => Otherwise return T as-is
type UnwrappedString = Unwrap<Promise<string>>; // => Type: string
type UnwrappedNumber = Unwrap<number>; // => Type: number (not Promise)
// CONDITIONAL TYPE FOR FILTERING
type NonNullable<T> = T extends null | undefined ? never : T; // => Remove null/undefined
// => If T is null or undefined, return never
// => Otherwise return T
type MaybeString = string | null | undefined;
type DefiniteString = NonNullable<MaybeString>; // => Type: string (null/undefined removed)
// CONDITIONAL TYPE WITH UNION DISTRIBUTION
type ToArray<T> = T extends any ? T[] : never; // => Distributes over union members
// => Each member wrapped in array
type StringOrNumber = string | number;
type ArrayTypes = ToArray<StringOrNumber>; // => Type: string[] | number[]
// => NOT (string | number)[]
// PRACTICAL EXAMPLE - API RESPONSE TYPE
interface SuccessResponse<T> {
status: "success";
data: T;
}
interface ErrorResponse {
status: "error";
error: string;
}
type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;
type ExtractData<T> = T extends { data: infer D } ? D : never; // => Extract data property type
type UserData = ExtractData<ApiResponse<{ id: number; name: string }>>;
// => Type: { id: number; name: string }
// CONDITIONAL TYPE FOR FUNCTION ARGUMENTS
type Parameters<T> = T extends (...args: infer P) => any ? P : never; // => Extract parameter types
function greet(name: string, age: number): void {
console.log(`${name} is ${age}`);
}
type GreetParams = Parameters<typeof greet>; // => Type: [string, number] (tuple)
const params: GreetParams = ["Alice", 30];
greet(...params); // => Output: Alice is 30
Key Takeaway: Conditional types use T extends U ? X : Y syntax for type-level conditions. Use infer to extract types from complex structures. Conditional types distribute over union types automatically.
Why It Matters: Conditional types power advanced type utilities (ReturnType, Parameters, NonNullable). They enable generic libraries to infer types from function signatures, Promise unwrapping, and discriminated union handling. Prop inference can use conditional types. Code generators can use them to transform schema types. This feature makes TypeScript Turing-complete at the type level.
Example 29: Recursive Types
Recursive types reference themselves in their definition. They model nested data structures like trees, linked lists, and JSON.
Code:
// RECURSIVE LINKED LIST
interface LinkedListNode<T> {
value: T;
next: LinkedListNode<T> | null; // => Self-reference
}
const node3: LinkedListNode<number> = { value: 3, next: null };
const node2: LinkedListNode<number> = { value: 2, next: node3 };
const node1: LinkedListNode<number> = { value: 1, next: node2 };
console.log(node1.value); // => Output: 1
console.log(node1.next?.value); // => Output: 2
console.log(node1.next?.next?.value); // => Output: 3
// RECURSIVE TREE
interface TreeNode<T> {
value: T;
children: TreeNode<T>[]; // => Array of self-references
}
const leaf1: TreeNode<string> = { value: "A", children: [] };
const leaf2: TreeNode<string> = { value: "B", children: [] };
const branch: TreeNode<string> = { value: "Root", children: [leaf1, leaf2] };
console.log(branch.value); // => Output: Root
console.log(branch.children[0].value); // => Output: A
// RECURSIVE JSON TYPE
type JSONValue = // => Recursive union
| string
| number
| boolean
| null
| JSONValue[] // => Array of JSONValue
| { [key: string]: JSONValue }; // => Object with JSONValue values
const jsonData: JSONValue = {
name: "Alice",
age: 30,
active: true,
address: {
city: "New York",
zip: 10001,
},
hobbies: ["reading", "coding"],
};
console.log(jsonData); // => Output: { name: 'Alice', age: 30, ... }
// RECURSIVE TYPE WITH CONSTRAINTS
type DeepReadonly<T> = {
// => Recursive readonly
readonly [P in keyof T]: T[P] extends object
? DeepReadonly<T[P]> // => Recursively apply to nested objects
: T[P]; // => Primitives stay as-is
};
interface Config {
database: {
host: string;
port: number;
};
features: string[];
}
type ReadonlyConfig = DeepReadonly<Config>;
const config: ReadonlyConfig = {
database: { host: "localhost", port: 5432 },
features: ["auth", "logging"],
};
// config.database.host = "prod"; // => ERROR: readonly property
// RECURSIVE FLATTEN TYPE
type Flatten<T> =
T extends Array<infer U> // => If T is array
? Flatten<U> // => Recursively flatten
: T; // => Base case: return T
type NestedArray = number[][][];
type Flattened = Flatten<NestedArray>; // => Type: number
// RECURSIVE PARTIAL TYPE
type DeepPartial<T> = {
// => Recursive optional
[P in keyof T]?: T[P] extends object
? DeepPartial<T[P]> // => Recursively apply to nested
: T[P];
};
type PartialConfig = DeepPartial<Config>;
const partialConfig: PartialConfig = {
database: {
host: "localhost", // => port omitted (optional)
},
};
console.log(partialConfig); // => Output: { database: { host: 'localhost' } }
Key Takeaway: Recursive types reference themselves in their definition using type names. Use them for nested structures (trees, lists, JSON). Combine with conditional types for recursive transformations (DeepReadonly, DeepPartial).
Why It Matters: Recursive types model real-world nested data—file systems, DOM trees, organizational charts, nested configuration. TypeScript’s type system can express arbitrarily deep nesting while maintaining safety. Schema types can use recursive structures. Component trees can use recursive prop types. This pattern is essential for data structures where depth is unknown at compile time.
Example 30: Const Assertions
Const assertions (as const) make literals and objects deeply readonly with literal types instead of widened types.
Code:
// WITHOUT const ASSERTION
let str1 = "hello"; // => Type: string (widened from literal)
let num1 = 42; // => Type: number (widened)
str1 = "world"; // => ALLOWED: str1 is mutable string
num1 = 100; // => ALLOWED: num1 is mutable number
// WITH const ASSERTION
let str2 = "hello" as const; // => Type: "hello" (literal type)
let num2 = 42 as const; // => Type: 42 (literal type)
// str2 = "world"; // => ERROR: Type '"world"' not assignable to '"hello"'
// num2 = 100; // => ERROR: Type '100' not assignable to '42'
// CONST ASSERTION ON OBJECTS
const point1 = { x: 10, y: 20 }; // => Type: { x: number; y: number }
point1.x = 15; // => ALLOWED: properties are mutable
const point2 = { x: 10, y: 20 } as const; // => Type: { readonly x: 10; readonly y: 20 }
// => Properties become readonly literals
// point2.x = 15; // => ERROR: Cannot assign to readonly property
// CONST ASSERTION ON ARRAYS
const arr1 = [1, 2, 3]; // => Type: number[]
arr1.push(4); // => ALLOWED: array is mutable
const arr2 = [1, 2, 3] as const; // => Type: readonly [1, 2, 3]
// => Becomes readonly tuple with literal types
// arr2.push(4); // => ERROR: push doesn't exist on readonly array
// arr2[0] = 10; // => ERROR: Cannot assign to readonly index
// CONST ASSERTION FOR ENUM-LIKE OBJECTS
const Colors = {
// => Without as const
Red: "red",
Green: "green",
Blue: "blue",
}; // => Type: { Red: string; Green: string; Blue: string }
const ColorsConst = {
Red: "red",
Green: "green",
Blue: "blue",
} as const; // => Type: { readonly Red: "red"; readonly Green: "green"; readonly Blue: "blue" }
type Color = (typeof ColorsConst)[keyof typeof ColorsConst]; // => Type: "red" | "green" | "blue"
function setColor(color: Color): void {
console.log(`Color set to ${color}`);
}
setColor(ColorsConst.Red); // => Output: Color set to red
// setColor("yellow"); // => ERROR: "yellow" not in Color
// CONST ASSERTION WITH FUNCTION RETURN
function getCoords() {
return { x: 10, y: 20 } as const; // => Return type: { readonly x: 10; readonly y: 20 }
}
const coords = getCoords(); // => Type inferred as readonly literals
console.log(coords.x); // => Output: 10
// coords.x = 15; // => ERROR: readonly property
// CONST ASSERTION FOR ROUTE CONFIGURATION
const routes = [
{ path: "/", component: "Home" },
{ path: "/about", component: "About" },
{ path: "/contact", component: "Contact" },
] as const; // => Readonly tuple with literal types
type Route = (typeof routes)[number]; // => Union of route objects
type RoutePath = (typeof routes)[number]["path"]; // => Type: "/" | "/about" | "/contact"
function navigateTo(path: RoutePath): void {
console.log(`Navigating to ${path}`);
}
navigateTo("/about"); // => Output: Navigating to /about
// navigateTo("/unknown"); // => ERROR: "/unknown" not in RoutePath
Key Takeaway: as const narrows types to literal types and makes objects/arrays deeply readonly. Use it to prevent widening of literals and to create immutable configurations.
Why It Matters: Const assertions eliminate type widening that loses precision. Configuration objects become truly immutable—no accidental mutations. Route definitions, action types, and enum-like objects use const assertions to create compile-time constants. This pattern bridges the gap between JavaScript’s const (only prevents reassignment) and true immutability with literal types.