Skip to content

TypeScript and Loops/Conditions

In the realm of loops and conditions, TypeScript (TS) and JavaScript (JS) share the same DNA. If you’re expecting a drastic difference in handling loops and conditions in TS compared to JS, you might be surprised to find a lot of similarities.

Similar Foundations

TypeScript, a superset of JavaScript, maintains the same fundamental structures for loops and conditions as JavaScript. Structures such as for, while, if, and switch statements work identically in both languages. You can refer to this internal article or the JavaScript documentation to grasp the essential concepts as they apply equally to TypeScript.

Example in JavaScript:

for (let i = 0; i < 5; i++) {
  if (i % 2 === 0) {
    console.log(i, "is even");
  }
}

Same Example in TypeScript:

for (let i = 0; i < 5; i++) {
  if (i % 2 === 0) {
    console.log(i, "is even");
  }
}

What TypeScript Adds

While the basic functionalities remain consistent with JavaScript, TypeScript enhances the development experience by offering static type checking and powerful type-related features that significantly improve flow control. These additions make code more robust, provide better IntelliSense, and catch errors at compile-time.

Type Guards and Narrowing

TypeScript can narrow types within conditional blocks, providing better type safety:

function processValue(value: string | number) {
    if (typeof value === "string") {
        // TypeScript knows 'value' is string here
        console.log(value.toUpperCase()); // ✅ String methods available
    } else {
        // TypeScript knows 'value' is number here
        console.log(value.toFixed(2)); // ✅ Number methods available
    }
}

Discriminated Unions

Discriminated unions (also known as tagged unions or algebraic data types) are a powerful TypeScript pattern that combines union types with a common property (the discriminant) to enable type-safe conditional logic.

What Makes a Discriminated Union?

A discriminated union has three key components:

  1. Multiple types with a common property
  2. A discriminant property - a literal type property that’s common to all types (usually called type, kind, or tag)
  3. A union type that combines all the types

Basic Example

interface Circle {
    kind: "circle";      // Discriminant property with literal type
    radius: number;
}

interface Rectangle {
    kind: "rectangle";   // Same property name, different literal value
    width: number;
    height: number;
}

interface Triangle {
    kind: "triangle";    // Same property name, different literal value
    base: number;
    height: number;
}

type Shape = Circle | Rectangle | Triangle;  // Union of all types

function calculateArea(shape: Shape): number {
    switch (shape.kind) {  // TypeScript uses 'kind' to narrow the type
        case "circle":
            // TypeScript knows this is Circle - radius is available
            return Math.PI * shape.radius * shape.radius;
        case "rectangle":
            // TypeScript knows this is Rectangle - width and height are available
            return shape.width * shape.height;
        case "triangle":
            // TypeScript knows this is Triangle - base and height are available
            return (shape.base * shape.height) / 2;
        default:
            // Exhaustiveness check - ensures all cases are handled
            const exhaustive: never = shape;
            throw new Error(`Unhandled shape: ${exhaustive}`);
    }
}

// Usage
const circle: Circle = { kind: "circle", radius: 5 };
const rect: Rectangle = { kind: "rectangle", width: 10, height: 20 };
console.log(calculateArea(circle));  // ~78.54
console.log(calculateArea(rect));    // 200

Why Use Discriminated Unions?

  1. Type Safety: TypeScript automatically narrows the type based on the discriminant
  2. Exhaustiveness Checking: Compiler ensures all cases are handled
  3. IntelliSense Support: Get autocomplete for properties specific to each type
  4. Refactoring Safety: Adding new types forces you to update all switch statements

Comparison with Polymorphism

Discriminated unions are similar to polymorphism in object-oriented programming, but with a more functional approach:

// Object-oriented approach with inheritance and polymorphism
abstract class ShapeOOP {
    abstract calculateArea(): number;
}

class CircleOOP extends ShapeOOP {
    constructor(public radius: number) {
        super();
    }
    calculateArea(): number {
        return Math.PI * this.radius * this.radius;
    }
}

class RectangleOOP extends ShapeOOP {
    constructor(public width: number, public height: number) {
        super();
    }
    calculateArea(): number {
        return this.width * this.height;
    }
}

// Usage with polymorphism
const shapes: ShapeOOP[] = [
    new CircleOOP(5),
    new RectangleOOP(10, 20)
];
shapes.forEach(shape => console.log(shape.calculateArea()));

// Functional approach with discriminated unions (shown earlier)
// More lightweight, easier to serialize, better for data structures

Key Differences:

  • Discriminated Unions: Better for data structures, easier to serialize/deserialize, more functional style
  • Class Polymorphism: Better when you need methods and behavior tied to the object, more object-oriented style

Both patterns solve the same problem: handling different types with a common interface. Choose discriminated unions for data-heavy applications and class polymorphism when you need encapsulated behavior.

Using with If Statements

You don’t have to use switch statements - if statements work too:

function describeShape(shape: Shape): string {
    if (shape.kind === "circle") {
        return `Circle with radius ${shape.radius}`;
    }

    if (shape.kind === "rectangle") {
        return `Rectangle ${shape.width}x${shape.height}`;
    }

    // TypeScript knows shape must be Triangle here
    return `Triangle with base ${shape.base}`;
}

Modern Operators for Safe Navigation

TypeScript supports modern JavaScript operators that help with flow control:

interface User {
    name: string;
    profile?: {
        avatar?: string;
        settings?: {
            theme: string;
        };
    };
}

function displayUserInfo(user: User | null | undefined) {
    // Optional chaining - safely access nested properties
    const theme = user?.profile?.settings?.theme;

    // Nullish coalescing - provide default for null/undefined
    const displayName = user?.name ?? "Anonymous";

    console.log(`${displayName} uses ${theme ?? "default"} theme`);
}

Enhanced Type Safety in Conditions

function isEven(num: number): boolean {
    if (num % 2 === 0) {
        console.log(`${num} is even`);
        return true;
    }
    return false;
}

isEven(4); // ✅ Correct
// isEven("4"); // ❌ Error: Argument of type 'string' is not assignable to parameter of type 'number'.

// More advanced example with union types
function processInput(input: string | number): string {
    if (typeof input === "string") {
        return `String: ${input.toUpperCase()}`;
    } else {
        return `Number: ${input.toFixed(2)}`;
    }
}

Switch Statements with Union Types

type Status = "loading" | "success" | "error";

function handleStatus(status: Status): string {
    switch (status) {
        case "loading":
            return "Please wait...";
        case "success":
            return "Operation completed!";
        case "error":
            return "Something went wrong.";
        default:
            const exhaustive: never = status;
            return exhaustive;
    }
}

TypeScript-Enhanced Loops

While loop syntax remains the same, TypeScript adds type safety and better tooling support:

Typed Arrays and Iteration

const numbers: number[] = [1, 2, 3, 4, 5];
const users: Array<{ name: string; age: number }> = [
    { name: "Alice", age: 30 },
    { name: "Bob", age: 25 }
];

// Traditional for loop with type safety
for (let i = 0; i < numbers.length; i++) {
    console.log(numbers[i].toFixed(2)); // TypeScript knows this is a number
}

// For-of loop with automatic type inference
for (const user of users) {
    console.log(`${user.name} is ${user.age} years old`); // Full IntelliSense support
}

// For-in loop (be careful with arrays!)
const userAges: Record<string, number> = { Alice: 30, Bob: 25 };
for (const name in userAges) {
    console.log(`${name}: ${userAges[name]}`);
}

Array Methods with Type Safety

TypeScript provides full type safety when using array methods. Here are the most common methods:

interface Product {
    name: string;
    price: number;
    category: string;
}

const products: Product[] = [
    { name: "Laptop", price: 999, category: "Electronics" },
    { name: "Book", price: 15, category: "Education" },
    { name: "Phone", price: 599, category: "Electronics" }
];

filter()

Creates a new array with elements that pass a test. The returned array has the same type as the original.

// Filter returns Product[] - same type as input
const expensiveProducts = products.filter(product => product.price > 500);
console.log(expensiveProducts);
// [{ name: "Laptop", price: 999, category: "Electronics" }, 
//  { name: "Phone", price: 599, category: "Electronics" }]

// Can also use type predicates for more advanced filtering
const hasValidPrice = (product: Product): product is Product => {
    return product.price > 0 && product.price < 10000;
};
const validProducts = products.filter(hasValidPrice);

map()

Transforms each element and returns a new array. TypeScript infers the return type from the transformation.

// Map to string[] - TypeScript infers the new type
const productNames: string[] = products.map(product => product.name);
console.log(productNames); // ["Laptop", "Book", "Phone"]

// Map to different structure
const productSummaries = products.map(product => ({
    title: product.name,
    cost: `$${product.price}`
}));
// Type: { title: string; cost: string; }[]

reduce()

Reduces an array to a single value by applying a function to each element. You must specify the initial value and return type.

// Sum all prices - returns number
const totalPrice: number = products.reduce((sum, product) => sum + product.price, 0);
console.log(totalPrice); // 1613

// Group by category - returns object
const byCategory = products.reduce((acc, product) => {
    if (!acc[product.category]) {
        acc[product.category] = [];
    }
    acc[product.category].push(product);
    return acc;
}, {} as Record<string, Product[]>);
// Type: Record<string, Product[]>
// Result: { Electronics: [...], Education: [...] }

find()

Returns the first element that matches a condition. Return type is T | undefined because the element might not exist.

// Find returns Product | undefined
const foundProduct: Product | undefined = products.find(p => p.category === "Electronics");

if (foundProduct) {
    console.log(foundProduct.name); // "Laptop"
} else {
    console.log("Not found");
}

// Can also use with type guards
const expensiveProduct = products.find(p => p.price > 900);
// Type: Product | undefined

Other Useful Methods

// some() - checks if at least one element passes the test
const hasExpensiveItem: boolean = products.some(p => p.price > 500);

// every() - checks if all elements pass the test
const allAffordable: boolean = products.every(p => p.price < 2000);

// findIndex() - returns index of first matching element, or -1
const index: number = products.findIndex(p => p.name === "Book");

Advanced Topics

The following sections cover more advanced flow control features that are useful for complex scenarios but may not be needed in everyday development.

Assertion Functions

Create functions that assert conditions and narrow types:

function assertIsNumber(value: unknown): asserts value is number {
    if (typeof value !== "number") {
        throw new Error("Expected number");
    }
}

function processUnknownValue(value: unknown) {
    assertIsNumber(value);
    // TypeScript now knows 'value' is number
    console.log(value.toFixed(2));
}

Generic Iteration Functions

Use generics to create reusable iteration functions:

function processItems<T>(items: T[], processor: (item: T) => void): void {
    for (const item of items) {
        processor(item);
    }
}

// Usage with different types
const numbers: number[] = [1, 2, 3];
const users: Array<{ name: string }> = [{ name: "Alice" }];

processItems(numbers, (num) => console.log(num.toFixed(2)));
processItems(users, (user) => console.log(user.name));