Skip to content

Latest commit

 

History

History
313 lines (211 loc) · 19 KB

File metadata and controls

313 lines (211 loc) · 19 KB

Claude's Guide to TypeScript for Humans

TypeScript fails humans in 3 places before anything else: using any to silence the type checker instead of modeling the domain correctly, treating TypeScript as "JavaScript with type annotations" rather than a type system with real expressive power, and writing types that describe implementation details instead of modeling the problem domain. TypeScript's value is proportional to how seriously you take it. A codebase that sprinkles any and as everywhere has purchased compile-time noise in exchange for zero safety. A codebase that models its domain in types has made an entire class of bugs structurally impossible.


The Type System Is the Design

The instinct arriving from JavaScript is to write the implementation first and add types afterward — annotating what you built. This produces the weakest possible TypeScript because the types are documenting decisions already made rather than constraining decisions being made.

The correct direction is reversed: model the domain in types first, then implement against that model. Types written before implementation force you to think precisely about what values can exist, what operations are valid, and what states are possible — before you are committed to an implementation that makes the wrong answers convenient.

// Wrong — implementation first, types retrofitted
function processOrder(order: any) {
    if (order.status === 'pending') { ... }
    if (order.status === 'shipped') { ... }
}

// Right — domain modeled first, implementation constrained by types
type OrderStatus = 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled'

type Order = {
    id: string
    status: OrderStatus
    items: OrderItem[]
    shippedAt: Date | null  // null when not yet shipped — explicit
}

function processOrder(order: Order): void { ... }

The second version makes illegal states visible. order.status === 'dispatched' is a compile error — not a runtime surprise. order.shippedAt being null before shipping is explicit in the type — not an undocumented possibility discovered in production.


any Is a Type System Ejection Seat

any does not mean "I don't know the type." It means "disable type checking for this value and everything it touches." It is contagious — a value typed as any assigned to a typed variable silently bypasses the type check. A function accepting any accepts every mistake you can make at the call site. any does not make TypeScript flexible. It makes TypeScript silent.

The legitimate uses of any are narrow: migration boundaries in codebases converting from JavaScript, true interop with untyped external systems where no better option exists, and occasionally in extremely generic utility types where the type parameter genuinely cannot be constrained. In application code, any is almost never correct.

The alternatives that should be used instead:

unknown for values whose type is genuinely not known at the point of use. Unlike any, unknown requires a type check before use — you cannot call methods on an unknown value or assign it to a typed variable without narrowing first:

// Wrong — any propagates silently
function parseConfig(raw: any) {
    return raw.database.host  // no error even if structure is wrong
}

// Right — unknown forces explicit narrowing
function parseConfig(raw: unknown): Config {
    if (!isConfig(raw)) throw new Error('Invalid config structure')
    return raw  // narrowed to Config after guard
}

Union types for values that can be 1 of several known things. If a value can be a string or a number or null, type it as string | number | null. Not any.

Generics for functions that operate on values of unknown-but-consistent type. If a function takes a value and returns it unchanged, <T>(value: T): T is correct. (value: any): any is not — it loses the relationship between input and output types.

as unknown as T as a last resort. When you genuinely must cast between unrelated types — usually at a boundary you own both sides of — as unknown as T is at least explicit about the double assertion. A bare as T assertion is weaker evidence because TypeScript will sometimes allow it without the intermediate unknown step, hiding the full unsafety.

Treat any occurrences in a codebase as technical debt with compound interest. Each one is a hole through which type errors travel silently until they become runtime failures.


Make Illegal States Unrepresentable

This is the highest-leverage TypeScript skill and the one most underused. If your type system can represent a state that should never exist, that state will eventually exist — created by a bug, a race condition, or an incorrect assumption. If your type system cannot represent that state, the bug cannot compile.

The canonical example is boolean flags that change what other fields mean:

// Wrong — isLoading, data, and error can be in contradictory combinations
type FetchState = {
    isLoading: boolean
    data: User | null
    error: Error | null
}
// Nothing prevents { isLoading: true, data: someUser, error: someError }
// That state is nonsense but the type allows it

// Right — each state is a distinct type, impossible to combine incorrectly
type FetchState =
    | { status: 'idle' }
    | { status: 'loading' }
    | { status: 'success'; data: User }
    | { status: 'error'; error: Error }

In the second version, status: 'success' without data is a compile error. status: 'loading' with data is a compile error. The impossible states are structurally impossible — not guarded by runtime checks, not documented in comments, not relied on by discipline. Impossible by construction.

Apply this to every type that has combinations of fields that are only valid together. Optional fields that are always present together should be a nested object or a discriminated union variant. Fields that are only meaningful in certain states belong in the type for that state.


Discriminated Unions and Exhaustive Checking

Discriminated unions — union types where each member has a common literal field used to distinguish them — are the most useful TypeScript pattern for modeling domain states. They compose naturally with TypeScript's narrowing and enable exhaustive checking that the compiler enforces:

type Shape =
    | { kind: 'circle'; radius: number }
    | { kind: 'rectangle'; width: number; height: number }
    | { kind: 'triangle'; base: number; height: number }

function area(shape: Shape): number {
    switch (shape.kind) {
        case 'circle':
            return Math.PI * shape.radius ** 2
        case 'rectangle':
            return shape.width * shape.height
        case 'triangle':
            return 0.5 * shape.base * shape.height
    }
}

Add a never check to make exhaustiveness a compile error rather than a silent runtime gap:

function area(shape: Shape): number {
    switch (shape.kind) {
        case 'circle': return Math.PI * shape.radius ** 2
        case 'rectangle': return shape.width * shape.height
        case 'triangle': return 0.5 * shape.base * shape.height
        default:
            const _exhaustive: never = shape
            throw new Error(`Unhandled shape: ${_exhaustive}`)
    }
}

Now add | { kind: 'pentagon'; sides: number[] } to Shape and the area function becomes a compile error immediately. Every switch site that handles Shape becomes a compile error. The compiler finds every place in the codebase that needs to be updated. This is not documentation — it is mechanical enforcement.


Strict Mode Is the Baseline

TypeScript's strict mode is not a pedantic setting for people who enjoy compiler errors. It is the configuration under which TypeScript provides the safety it promises. Without it, null and undefined can be assigned to any type silently, function parameters have implicit any, and the majority of the real-world type errors TypeScript catches become invisible.

tsconfig.json with "strict": true is the starting point. Not the advanced configuration — the minimum viable configuration:

{
    "compilerOptions": {
        "strict": true,
        "noUncheckedIndexedAccess": true,
        "exactOptionalPropertyTypes": true
    }
}

noUncheckedIndexedAccess adds undefined to array and object index access return types — because array[index] genuinely can be undefined if index is out of bounds, and pretending otherwise is a common source of runtime errors.

exactOptionalPropertyTypes distinguishes between a property being absent and a property being explicitly undefined — they are different things and TypeScript without this flag treats them identically.

Every TypeScript project that does not have strict mode enabled is a project making a choice to accept preventable bugs in exchange for easier type annotations. That is almost never the right tradeoff.


Type Narrowing and Type Guards

TypeScript narrows types automatically in conditional branches — after if (typeof x === 'string'), TypeScript knows x is a string in that branch. This narrowing is the mechanism by which unknown becomes useful and discriminated unions become ergonomic.

Understand what triggers narrowing: typeof, instanceof, in checks, equality checks against literal values, truthiness checks, and user-defined type guards.

User-defined type guards are functions that return value is Type — they extend TypeScript's narrowing to custom checks:

function isUser(value: unknown): value is User {
    return (
        typeof value === 'object' &&
        value !== null &&
        'id' in value &&
        'email' in value &&
        typeof (value as any).id === 'string' &&
        typeof (value as any).email === 'string'
    )
}

const raw: unknown = fetchFromAPI()
if (isUser(raw)) {
    console.log(raw.email)  // typed as User here
}

The type guard centralizes the runtime check. Every call site benefits from narrowing without repeating the check. Changes to the User type require updating 1 guard function, not every place that validates a user.

zod, valibot, and similar validation libraries generate type guards automatically from schema definitions — they are worth using at any boundary where data enters your system from outside: API responses, form inputs, configuration files, localStorage. The schema is the single source of truth for both the runtime validation and the TypeScript type.


Generics Are Not Advanced TypeScript

Generics are avoided by developers who think of them as an advanced feature for library authors. This is a mistake that produces duplicated code, any in function signatures, and lost type information at every abstraction boundary.

A generic function preserves the relationship between its inputs and outputs. A non-generic function loses that relationship:

// Wrong — loses type information, forces caller to cast
function first(arr: any[]): any {
    return arr[0]
}
const name = first(['Alice', 'Bob'])  // typed as any, not string

// Right — preserves type information
function first<T>(arr: T[]): T | undefined {
    return arr[0]
}
const name = first(['Alice', 'Bob'])  // typed as string | undefined

Constrain generics when the type parameter must have specific capabilities:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key]
}

const user = { id: '1', name: 'Alice', age: 30 }
const name = getProperty(user, 'name')  // typed as string
const age = getProperty(user, 'age')    // typed as number
getProperty(user, 'email')              // compile error — 'email' not in keyof User

The K extends keyof T constraint makes invalid property access a compile error. The return type T[K] preserves the exact type of the property accessed. Neither of these properties exists without generics — they require modeling the relationship between types.


Utility Types Are Tools Not Tricks

TypeScript's built-in utility types — Partial, Required, Readonly, Pick, Omit, Record, Exclude, Extract, ReturnType, Parameters, Awaited — exist because the patterns they encode are common enough to deserve names. Using them is not showing off. Not using them is reimplementing them badly.

The most misused:

Partial<T> for update operations. When updating a record, not all fields are required. Partial<User> makes every field optional — correct for patch operations, wrong as a general replacement for optional fields in a domain type.

Pick<T, K> and Omit<T, K> for derived types. When a function needs only part of a larger type, Pick defines that subset without duplicating the field definitions. When a type is like another type but without certain fields, Omit derives it. Both keep the derived type in sync with the source automatically — a field added to User is automatically in Pick<User, 'id' | 'email'> if it is one of the picked keys.

ReturnType<typeof fn> for types derived from function return values. When a function's return type is complex and you need to reference it elsewhere, ReturnType extracts it without duplicating the definition. The type stays in sync with the function automatically.

Record<K, V> for mapped object types. Record<string, number> is cleaner than { [key: string]: number } and Record<OrderStatus, OrderHandler> ensures every status has a handler — a Record with a union key is exhaustive by construction.


as Assertions Are Promises to the Compiler You Must Keep

Type assertions — value as SomeType — tell the compiler "trust me, I know this is SomeType." The compiler stops checking and trusts you. If you are wrong, the error surfaces at runtime, not at compile time, in whatever code later uses the value expecting it to be SomeType.

as is not wrong. It is a contract. You are taking responsibility for a type relationship the compiler cannot verify. Treat it with that weight:

Every as assertion should have a comment explaining why it is safe. "The API guarantees this field is always present when status is 'success'." "This cast is safe because we validate the shape in the constructor." If you cannot write that comment, you do not know the assertion is safe.

Prefer type guards over assertions where possible. A type guard is a verified narrowing — the runtime check and the type information are consistent. An assertion is an unverified claim — correct by discipline, not by construction.

as unknown as T between unrelated types is a strong signal to stop and question the design. If 2 types are structurally unrelated and you are casting between them, something in the domain model is wrong. The cast is a symptom. Find the design problem.


The TypeScript Pitfalls Most Guides Skip

Structural typing means assignability is not always what you want. TypeScript uses structural typing — a type is assignable to another if it has at least the required properties. This means 2 types that happen to have the same fields are interchangeable even if they represent completely different concepts:

type UserId = string
type ProductId = string

function getUser(id: UserId): User { ... }

const productId: ProductId = 'prod_123'
getUser(productId)  // no error — both are just string

For domain identifiers where mixing types is a bug, use branded types:

type UserId = string & { readonly _brand: 'UserId' }
type ProductId = string & { readonly _brand: 'ProductId' }

function createUserId(id: string): UserId {
    return id as UserId
}

getUser(productId)  // compile error — ProductId not assignable to UserId

The brand is a phantom type — it exists only at the type level, never at runtime. The string is still a string. But the compiler now distinguishes them.

Optional chaining and nullish coalescing do not help if nullability is not in the type. ?. and ?? are useful exactly when the type includes null or undefined. If your types are not nullable — because you are using any or ignoring strict null checks — these operators provide false comfort. The type system must reflect actual nullability for the operators to mean anything.

enum has surprising runtime behavior. TypeScript enum generates runtime JavaScript — it creates an actual object. This is sometimes correct and frequently surprising. Numeric enums allow reverse lookup (Direction[0] returns 'Up'), which opens an unexpected surface area. String literal union types (type Direction = 'up' | 'down' | 'left' | 'right') are almost always preferable — no runtime artifact, no reverse lookup surprise, treeshakeable, and serializable without conversion.

Declaration merging is powerful and dangerous. TypeScript allows interfaces with the same name to be merged — declaring the same interface twice combines the definitions. This is intentional for extending third-party types. It is a source of confusing behavior when it happens accidentally across files. Prefer type aliases over interface for application types where merging is not intended — type aliases cannot be merged.

Object.keys() returns string[], not (keyof T)[]. This is technically correct — objects at runtime can have keys not present in their static type — and practically annoying. The safe cast is (Object.keys(obj) as (keyof typeof obj)[]) when you genuinely know the object has exactly the typed keys and no others.

Promise errors are unknown in TypeScript 4.0+. Catch clause variables changed from any to unknown in strict mode. catch (e) requires narrowing e before use. This is correct — thrown values genuinely can be anything — and requires updating old code that assumed e.message exists.


What Good TypeScript Actually Looks Like

Strict mode enabled with noUncheckedIndexedAccess and exactOptionalPropertyTypes. Domain modeled in types before implementation. Illegal states made unrepresentable through discriminated unions. any treated as technical debt requiring justification. unknown at boundaries with explicit type guards for narrowing. Generics preserving type relationships across abstractions. Utility types used idiomatically. as assertions accompanied by explanations of why they are safe. Branded types for domain identifiers that must not be confused. Validation libraries at system boundaries turning runtime checks into type narrowing.

TypeScript at its weakest is JavaScript with red squiggles that any makes go away. TypeScript at its strongest is a domain modeling language where the compiler enforces business rules, impossible states cannot compile, and refactoring is safe because every downstream effect of a type change is a compile error rather than a runtime surprise discovered in production.

The distance between those 2 TypeScript codebases is not framework choice or library selection. It is whether the developers took the type system seriously as a design tool or treated it as a documentation layer on top of JavaScript they were going to write anyway.

Take it seriously. The compiler is on your side. Let it help.