Learn TypeScript - Strategies for Scalable Type Definitions
Episode 19 of 23

Learn TypeScript - Strategies for Scalable Type Definitions

This episode covers designing types that stay easy to maintain as the project grows: modeling domains with discriminated unions, brand types for distinguishing identical values, composition, type file organization, and the principle of minimizing any while maximizing unknown.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

In a small project, any type feels good enough. In a large project with dozens of developers, type design determines development speed. Poorly designed types become an obstacle; well-designed types become documentation that guides everyone to write correct code.

Scalable means types stay expressive without becoming complicated. You don't want a confusing giant union, or types that force constant conversions because the source of truth was designed wrong at the start.

Episode 19 covers patterns proven in large codebases: modeling domains with discriminated unions, distinguishing same-shaped values with brand types, simple composition, consistent file organization, and the discipline of avoiding any.

Domain Modeling with Discriminated Unions

State as One Model

The discriminated union from episode 11 becomes the foundation for modeling domains. Its advantage appears when used consistently as a single state type:

Model state dengan discriminated union
type KeadaanPesan =
    | { status: "mengirim"; progress: number }
    | { status: "terkirim"; id: string }
    | { status: "gagal"; alasan: string };
 
function deskripsi(k: KeadaanPesan): string {
    switch (k.status) {
        case "mengirim":
            return `Mengirim ${k.progress} persen`;
        case "terkirim":
            return `Terkirim dengan id ${k.id}`;
        case "gagal":
            return `Gagal: ${k.alasan}`;
    }
}

Each union member carries the data relevant to its state. No optional fields pile up, no invalid combination can occur. The compiler forces every state to be handled, and the model grows by adding union members alone.

One Type, Many Views

The power of this pattern: the whole application uses one state type, then each module derives the view it needs. Changes to the domain model are automatically checked across every usage. This is how you add features without tearing apart many files.

Brand Types and Nominal Typing

TypeScript uses structural typing: two types with the same shape are considered the same. Sometimes that's dangerous:

Brand type
type IDPengguna = string & { readonly brand: unique symbol };
type IDOrder = string & { readonly brand: unique symbol };
 
function cariUser(id: IDPengguna): void {}

The declaration type IDPengguna = string & { readonly brand: unique symbol } creates a type structurally distinct from a plain string and from IDOrder. The cariUser function only accepts values deliberately marked as a user ID, so it can't be confused with an order ID or an arbitrary string.

The rule: use brand types when two values from different domains have the same shape, like IDs of two entities or different units. Without this, swapped values are only caught in production.

Composition and Type File Organization

Composition over Inheritance

Build types from small pieces that are combined:

Komposisi tipe
interface DapatDibuat {
    dibuat: Date;
}
 
interface DapatDiubah {
    diubah: Date;
}
 
type CatatanLengkap = DapatDibuat & DapatDiubah & {
    isi: string;
};

Types are split into small, reusable agreements. Adding a dibuat property to any entity is just an intersection. Composition avoids duplication and makes new types cheap to create.

Clear Folder Structure

Consistent placement helps scale:

Struktur folder tipe
src/
  types/
    domain.ts
    api.ts
    shared.ts
  index.ts

A types folder holds domain definitions and API contracts in a central place, exported through a single index.ts barrel. Other modules import from one door. These clear boundaries keep types from spreading to unexpected places and make dependency traces easy to map.

Minimizing any, Maximizing unknown

Scalable types start with discipline: any is a failure point that spreads.

unknown sebelum any
function ambilData(): unknown {
    return fetchDataKasar();
}

An unknown return forces the caller to validate or narrow before using the value. Unlike any, which frees everything right away. In episode 22 you'll see compiler options that enforce this discipline automatically.

Warning

When you find any in a codebase, the right treatment isn't to add more any, but to narrow at one entry point and spread the now-certain type. A single any can pollute other types that depend on it.

Closing

Episode 19 teaches type design that grows with the project: discriminated unions to model domains, brand types to distinguish similar values, organized composition and folders, and the discipline of minimizing any.

Key takeaways:

  • Discriminated unions model state with one source of truth.
  • Brand types prevent same-shaped values from being swapped.
  • Type composition is easier to maintain than deep inheritance.
  • A centralized types folder is exported through an index barrel.
  • unknown forces validation; any releases every guarantee.
  • Good types become documentation that guides developers.

In the next episode 20 we'll discuss build output, bundling, and source maps — controlling the compilation result from tsc up to production artifacts.

Learn TypeScript - Strategies for Scalable Type Definitions | Learn TypeScript