Return to TSMarkdown
Domain vocabulary: Product catalog

A supplier feed reports product-state snapshots. The numeric field is the product’s price; its units and precision follow the exercise contract.

Entity: Product · ID: productId · Numeric field: price

The domain changes example names. Validation, key scope, ordering and failure behavior come from each exercise’s contract.

Returning to TypeScript: the language

A practical refresher. Read the contract, trace the examples, and attempt the exercise before opening its answer. Code blocks may share context; only ts check blocks are standalone checks.

Types and values

A value exists at runtime; a type describes permitted values to the checker. Ordinary annotations, type aliases and interfaces are erased, so they do not validate JSON. Some TS constructs such as a normal enum generate runtime code. Use lowercase string, number and boolean rather than wrapper types. number includes integers and fractions; bigint is separate and cannot be mixed directly with number arithmetic.

const limit: number = 4;
type Options = { limit: number };

Inference and widening

let can be reassigned, so a string literal typically widens to string. const keeps a literal for a primitive binding, but an object property remains mutable and often widens. Add an explicit contract when building a state object.

as const narrows literals and produces readonly views; satisfies checks the shape but may retain a narrow literal instead of widening it.

Give empty collections context when incrementally filling them; do not memorize a universal rule that every empty array is never[].

let status = "queued";
const mode = "queued";
const job = { status: "queued" };
type Job = { status: "queued" | "done" };
const job: Job = { status: "queued" };
job.status = "done";
const state: { tags: string[] } = { tags: [] };
state.tags.push("new");

Structural typing

Compatibility normally depends on members rather than the declared name. An object with required members and additional fields may be assignable through a variable. A fresh object literal also receives excess-property checks to catch likely mistakes. Those checks do not make an exact runtime schema or remove extra fields. Two aliases of string remain compatible; brands can distinguish IDs when this matters.

type Identified = { id: string };
function show(value: Identified): string {
  return value.id;
}
const product = { id: "A", price: 500 };
show(product);
type Identified = { id: string };
function show(value: Identified): string { return value.id; }
// @ts-expect-error: intentional type error
show({ id: "A", price: 500 });

Unions and intersections

A | B allows one of the alternatives; only operations safe for all members are available until narrowing. A & B must satisfy both contracts simultaneously, so conflicting property types can produce never.

An intersection does not overwrite a property with the right side: omit the original key first when replacing its type. Model mutually exclusive states as a union.

function format(value: string | number): string {
  return typeof value === "number" ? value.toFixed(2) : value.trim();
}
type Impossible = { id: string } & { id: number };

Optional versus undefined

With exactOptionalPropertyTypes, field?: number permits absence but not an explicit undefined unless included in the value union. Reading the field can still produce undefined. field: number | undefined requires the property itself. PATCH often assigns different meanings to absence and null. in includes inherited properties; Object.hasOwn checks ownership but is not always enough to narrow the value type.

type Options = { retries?: number };
const omitted: Options = {};

Arrays, tuples and readonly

An ordinary array has no minimum length. Under noUncheckedIndexedAccess, an indexed read can be undefined. A tuple describes fixed positions, and labels are editor information rather than runtime named fields. Prefer readonly inputs for functions that only read: both mutable and readonly callers can use them. Readonly is shallow and another alias can still mutate the underlying object.

const prices: number[] = [100, 200];
const point: readonly [x: number, y: number] = [10, 20];

Function signatures

Context provides callback parameter types. An optional parameter means callers may omit it; do not mark index optional when the API always passes it simply because some callbacks ignore it. Rest parameters collect arguments. Async functions return Promise<T>. A callback typed () => void may return a value because the caller promises not to use it; void is not a runtime proof that nothing is returned.

type Formatter = (price: number, currency?: string) => string;
const format: Formatter = (price, currency = "USD") => `${currency} ${price}`;

This and closures

A normal method gets this from its call site and can lose its receiver when passed as a callback. bind, a wrapper or an arrow field can preserve the intended receiver. A closure retains access to a variable rather than an automatic snapshot. let provides an iteration-local binding; var can make callbacks share one changing binding.

class Counter {
  value = 0;
  increment() { this.value++; }
}
const counter = new Counter();
const safe = () => counter.increment();
safe();

Passing counter.increment to a scheduler detaches the method from its object. The receiver is determined by the later call, not by where the method was obtained.

bind creates a function with the receiver attached; a wrapper calls the method on the object when needed.

A closure reads the binding when invoked, so changing count before the call changes the result.

Check both a direct call and a detached callback: testing only counter.increment() cannot expose the bug.

class Counter {
  value = 0;
  increment() { this.value++; }
}
const counter = new Counter();
const detached = counter.increment;
// detached() would throw in this strict module: this is undefined.
const bound = counter.increment.bind(counter);
bound(); // value is 1.
let count = 0;
const readCount = () => count;
count = 2;
readCount(); // 2: the closure reads the current variable, not its old value.

Practice

Why can an inferred job status fail a literal-union contract? Why can Readonly<{ tags: string[] }> still permit push? How does an optional field differ from a required field containing undefined?

Spoiler

The inferred property can be string; annotate the intended state contract. Readonly prevents replacing tags but its array needs readonly string[] to prevent mutation through that view. An optional property may be absent; the required union property must exist. exactOptionalPropertyTypes additionally controls explicitly writing undefined.

Move on when you can explain

Explain type versus value, write a callback signature, distinguish missing from null, and narrow unknown data without assertions. Then use the runtime chapter and practical TypeScript chapter to put these rules into working code.

Official references

Mark after your own attempt and answer review.