The type layer

Inference, widening, tuples, object and function types, unions, narrowing, generics, assertions, satisfies, and utilities.

The idea

Everything before this page described one program. This page adds a second one, written in the same file, which runs at compile time and then vanishes.

Take vanishes literally. Nothing on this page exists when the code runs, so every rule here is about what the checker will let you write — never about what happens afterwards. The two programs live in separate worlds, and the language keeps them separate with a rule worth naming now: type space and value space are different namespaces. const x = 1 puts x in value space; type X = number puts X in type space; a class unusually puts a name in both. Several keywords appear in both worlds meaning different things — typeof in a type position asks the checker a question and has nothing to do with the runtime operator from file 01.

Three mechanisms carry the file: widening (how precise a type the checker chooses when you did not say), narrowing (how it learns more as your code runs), and generics (how one type gets tied to another). Everything else is notation.

Inference and widening

Inference asks “what is the most useful type here”, and useful means as loose as the location is mutable. A value that can never change keeps its exact type; anything you could reassign gets widened.

const inferred = 1;   // type is 1 — the literal type
let   widened  = 1;   // type is number — a let could hold any number later

A literal type is a type with exactly one value in it. Once those exist, a union of them is an enum, and much of TypeScript’s expressiveness follows. as const says “none of this changes”, so nothing widens — all the way down, with arrays becoming readonly tuples. It is the answer to most “why won’t my string fit this union” questions.

Four types describe the edges of the system, and they are not interchangeable:

Objects, arrays, and being honest about absence

A tuple is an array whose positions are tracked individually, with optional elements, rest elements, and labels — labels being documentation with no effect on assignability.

interface versus type repeats file 03’s split: an interface is open (declare it again and members merge) and a type alias is closed but can name unions and primitives. Two more distinctions matter more than they look:

Two strict flags in this project exist to stop the type layer from lying about absence, and both are worth understanding as positions, not settings:

Function types have two deliberate holes

Both look like bugs. Read each as “what would the caller be allowed to do?” and they make sense.

Fewer parameters is fine. A function that ignores arguments is always safe to use where more are offered — which is why arr.map(x => x) type-checks when map supplies three.

Anything is assignable to a void return. void means the caller will not look at the result, not that there is no result — which is why list.forEach(x => arr.push(x)) compiles even though push returns a number.

Unions, and the one pattern to take away

| means one of these; & means all at once. Straightforward until you notice that & on primitives usually gives never, since no value is both a string and a number.

The discriminated union — a set of object types sharing a literal property that tells them apart — is the pattern to take from this file if you take only one. The shared tag lets the checker eliminate variants, which turns “did I handle every case” into a compile error. The never assignment in the default branch is the trick that does it: if a variant is ever added, there is suddenly something left to assign, and nothing is assignable to never.

Narrowing is a type at a position

Narrowing is a property of a position in the code, not of the variable. The checker follows your control flow and knows more inside a branch than outside it. Everything that narrows:

Both of the last two are promises rather than proofs: nobody verifies the body actually tests for what it claims.

And narrowing can be lost. Anything the checker cannot prove stayed put — a mutable variable, a property that a call might have changed — resets to the declared type. That is honesty, not a limitation: copy into a const and the narrowing survives, because nothing can reassign it.

! — the non-null assertion — is you promising there is no null here. No check is emitted, so if you are wrong you get exactly the crash you were trying to prevent.

Generics tie two positions together

A type parameter is not a placeholder for “some type”. It is a way of saying two places must agree: what goes in and what comes out are the same type, whatever that turns out to be. The practical test — if a type parameter appears only once in a signature, you probably wanted unknown instead.

Generics apply to functions, classes, interfaces, and individual methods, and a method-level parameter is independent of the class’s.

Annotation, assertion, satisfies

Three tools that look interchangeable. Ask two questions of each: does it check, and does it replace the type you had?

checks? replaces?
annotation const p: Palette = … yes yes — you now see the value through the wider type
assertion … as Palette no yes — it overrules the checker and emits nothing
satisfies Palette yes no — keeps the precise type you wrote

satisfies is usually the one you wanted: it verifies the constraint while keeping the narrow inferred type, so a tuple stays a tuple instead of collapsing into the annotation’s union. An assertion is not a conversion and not a cast — it is an instruction to stop checking, which is why as unknown as T for unrelated types is at least honest about what it is doing.

Utility types

None of them are magic. Every one is a mapped or conditional type you could write yourself — file 09 shows exactly how — so learn them by name to avoid rebuilding them: Partial, Required, Readonly, Pick, Omit, Record, Exclude, Extract, NonNullable, ReturnType, Parameters, InstanceType, Awaited, the case-mapping ones, and NoInfer.

Where this leaves you

You can now read most annotated TypeScript: what the checker inferred, where it learned more, and what a generic is holding together. The mental model is small — types are a second program over the same text, and every question is “what does this position allow”.

File 09 keeps the same model and adds computation to it. Conditional types give the type layer a branch, mapped types give it a loop, recursion gives it depth, and infer gives it a way to bind a name to whatever it found. At that point the type layer is a small programming language you are writing in, and the utility types above stop being vocabulary and become examples.

While you read

The file

Read one construct at a time and state the type before reading the comment. Nothing here prints anything interesting — the point is that it compiles. Mark anything you cannot explain with // ? in the source.

// 08 — THE TYPE LAYER: ANNOTATIONS, UNIONS, GENERICS, NARROWING
//
// A reading file. Types are erased: nothing in this file exists at run time, so
// the interesting answer is usually a TYPE rather than a value. Two markers:
//
//   // type: X   what the checker infers here, in its own words
//   // =>  v     what the expression evaluates to, on the rare line that runs
//
// Read it straight down; each section uses only what the lines above it
// introduced. It still runs (`node 08-types-basics.ts`) and prints nothing —
// the point of this file is that it COMPILES.
//
// Mark anything you can't explain out loud with  // ?

// ─── 1. ANNOTATIONS AND INFERENCE ────────────────────────────────────────────
//
// Inference asks "what is the most useful type here", and USEFUL means as loose
// as the location is mutable. A value that can never change keeps its exact
// type; anything you could reassign gets widened. That one idea explains most of
// the surprises in this file.

const inferred = 1; // type: 1 — a const gets a LITERAL type
let widened = 1; // type: number — a let WIDENS, because you could reassign it
const annotated: number = 1; // type: number — the annotation defeats literal inference

const explicitAny: any = 1; // type: any — opts out of checking entirely, and spreads
const explicitUnknown: unknown = 1; // type: unknown — the TOP type: safe, but unusable
// until narrowed
let neverAssigned: never; // type: never — the BOTTOM type: no value is assignable to it
function returnsVoid(): void {} // type: () => void — no meaningful return value

const voidResult = returnsVoid(); // type: void — and `undefined` at run time. `void` is
// a claim about what you may DO with the result, not about what is in it.

// ─── 2. PRIMITIVE AND LITERAL TYPES ──────────────────────────────────────────
//
// A literal type is a type with exactly one value in it. Once you have those, a
// union of them is an enum, and most of TypeScript's expressiveness follows.

type Primitives = string | number | boolean | symbol | bigint | null | undefined;
type StringLiteral = "on" | "off"; // LITERAL TYPES — a value used as a type
type NumericLiteral = 200 | 404;
type TemplateLiteral = `on-${string}`; // TEMPLATE LITERAL TYPE — a pattern, not a string

const literalUnion: StringLiteral = "on"; // type: StringLiteral — the ANNOTATION wins, so
// the type is the whole union even though the value is one member of it
const templated: TemplateLiteral = "on-click"; // type: `on-${string}`
// `const wrong: TemplateLiteral = "off-click"` is TS2322: the pattern is checked.

// `as const` says "none of this changes", so nothing widens — all the way down,
// and arrays become readonly tuples. It is the answer to most "why won't my
// string fit this union" questions (35.5).
const config = { mode: "dark", sizes: [1, 2] } as const;
// type: { readonly mode: "dark"; readonly sizes: readonly [1, 2]; }
type Mode = typeof config.mode; // type: "dark" — not string
type Sizes = typeof config.sizes; // type: readonly [1, 2] — not number[]

const mutableVersion = { mode: "dark", sizes: [1, 2] };
// type: { mode: string; sizes: number[]; } — the same source text without `as const`

// ─── 3. ARRAYS, TUPLES, OBJECTS ──────────────────────────────────────────────
//
// A tuple is an array whose positions are tracked individually. Everything else
// here is about being honest with yourself: index signatures and optional
// properties both describe things that might not be there.

type Arr1 = string[];
type Arr2 = Array<string>; // identical to Arr1 — two spellings, one type
type ReadonlyArr = readonly string[]; // no push/pop at the TYPE level; the array itself
// is as mutable as ever at run time
type Tuple = [string, number]; // fixed length, positional types
type NamedTuple = [name: string, age: number]; // the labels are documentation only
type OptionalTuple = [string, number?];
type RestTuple = [string, ...number[]];
type LeadingRest = [...number[], string]; // a rest element may lead, not only trail

const tuple: NamedTuple = ["a", 1];
const [tupleName] = tuple; // type: string — position 0, tracked individually
const tupleLength = tuple.length; // type: 2 — a tuple knows its own length as a literal
const arrayLength = (["a", 1] as (string | number)[]).length; // type: number

interface Interface { // INTERFACE — open: it can be re-declared and merged
  required: string;
  optional?: number; // `| undefined` AND may be absent
  readonly frozen: boolean;
  method(a: string): void; // METHOD SYNTAX — bivariant parameters
  property: (a: string) => void; // PROPERTY SYNTAX — contravariant under strictFunctionTypes
  [index: string]: unknown; // INDEX SIGNATURE
}

type ObjectType = { // TYPE ALIAS — closed, but it can express unions and primitives too
  a: string;
};

interface Extended extends Interface { extra: true }
type Intersected = ObjectType & { b: number }; // `&` is the alias equivalent of `extends`

// An index signature is a promise about EVERY key, not a fallback for the ones
// you did not list. A template literal key constrains the shape of the key itself.
interface DataAttrs {
  [key: `data-${string}`]: string;
}
const attrs: DataAttrs = { "data-id": "1" };
// `{ id: "1" }` would be TS2353: the key does not match the pattern.

// noUncheckedIndexedAccess makes indexing honest: lookup["absent"] really might
// not be there, so it comes back with `| undefined` attached and has to be dealt
// with. Mildly annoying; catches real bugs.
const lookup: Record<string, number> = {};
const maybeNumber = lookup["absent"]; // type: number | undefined — not number

// exactOptionalPropertyTypes makes `?` honest. Without it, "optional" quietly
// also means "may be explicitly undefined", a distinction the runtime genuinely
// makes (37.4).
type Exact = { maybe?: number };
const absent: Exact = {}; // fine: the property is missing
// `const present: Exact = { maybe: undefined }` is TS2375 — present, and holding
// undefined, is a third state the type did not allow for.

// ─── 4. FUNCTION TYPES ───────────────────────────────────────────────────────
//
// Two rules here look like bugs and are both deliberate conveniences. Read each
// as "what would the caller be allowed to do?" and they make sense.

type Fn = (a: string, b?: number) => void;
type Ctor = new (a: string) => object; // CONSTRUCT SIGNATURE
type Callable = { (a: string): void; description: string }; // callable AND with properties
type Overloads = { (a: string): string; (a: number): number };

// Fewer parameters is fine, because ignoring an argument is always safe. It is
// why `arr.map(x => x)` works when map offers the callback three arguments.
const takesFewer: (a: string, b: number) => void = (a) => void a;

// A `void` return type means "the caller will not look at this", not "returns
// nothing" — so anything is assignable to it. That is why
// `list.forEach(x => arr.push(x))` compiles even though push returns a number (35.3).
const returnsSomething: () => void = () => 1;
const ignoredResult = returnsSomething(); // type: void — the 1 is really there at run
// time, and the type layer refuses to let you use it

// ─── 5. UNIONS, INTERSECTIONS, DISCRIMINATED UNIONS ──────────────────────────
//
// `|` means one of these; `&` means all at once. Simple, until you notice that
// `&` on primitives usually gives you `never`, since nothing is both.

type Union = string | number;
type Intersection = { a: 1 } & { b: 2 }; // must satisfy BOTH
type Impossible = string & number; // type: never — no value is both

// THE TAGGED (DISCRIMINATED) UNION. If you take one pattern from this file, take
// this one: a shared literal property lets the checker tell the variants apart,
// which turns "did I handle every case" into a compile error (36.3).
type Result =
  | { status: "ok"; data: string }
  | { status: "error"; error: Error };

function handle(r: Result) {
  switch (r.status) {
    case "ok":
      return r.data; // narrowed to the "ok" variant — `.error` is not accessible here
    case "error":
      return r.error.message;
    default: {
      // EXHAUSTIVENESS CHECK — `r` is `never` here only because every variant was
      // handled above. Add a third variant and this line stops compiling.
      const exhaustive: never = r;
      return exhaustive;
    }
  }
}
const handled = handle({ status: "ok", data: "d" }); // => "d", type: string

// ─── 6. NARROWING — EVERY MECHANISM ──────────────────────────────────────────
//
// Narrowing gives a type to a POSITION, not to a variable. It follows your code
// as it runs, and it can be lost again — which is what the end of this section
// is about.

function narrowing(v: string | number | string[] | null | undefined | Date) {
  if (typeof v === "string") return "typeof: " + v.toUpperCase();
  if (Array.isArray(v)) return "isArray: " + v.length;
  if (v instanceof Date) return "instanceof: " + v.getTime();
  if (v == null) return "== null catches BOTH null and undefined";
  if (!v) return "truthiness narrowing"; // removes 0 from the remaining number
  return "remaining: " + v.toFixed(); // type of `v` here: number
}
const narrowedString = narrowing("s"); // => "typeof: S"
const narrowedNumber = narrowing(1); // => "remaining: 1"
const narrowedNullish = narrowing(null); // => "== null catches BOTH null and undefined"
const narrowedZero = narrowing(0); // => "truthiness narrowing" — 0 is falsy, so it never
// reaches the line that would have worked perfectly well for it

function inOperator(v: { a: string } | { b: number }) {
  return "a" in v ? v.a : v.b; // the `in` OPERATOR narrows object unions
}
const throughIn = inOperator({ a: "a" }); // => "a"

// USER-DEFINED TYPE GUARD. Nobody verifies that the body tests for what it
// claims — this is a promise, not a proof (36.5).
function isStringArray(v: unknown[]): v is string[] {
  return v.every((x) => typeof x === "string");
}
const guarded = isStringArray(["a"]); // => true

// ASSERTION FUNCTION — narrows everything AFTER the call, on the grounds that if
// it did not throw, the claim holds.
function assertDefined<T>(v: T, msg = "undefined"): asserts v is NonNullable<T> {
  if (v == null) throw new Error(msg);
}
const maybeValue: string | undefined = "here";
assertDefined(maybeValue);
const nowDefined = maybeValue.length; // => 4 — `maybeValue` is `string` from here down

// Since 5.5 the predicate often need not be written at all: if a function
// obviously computes one, the checker infers it.
function inferredPredicate(v: string | number) {
  return typeof v === "string"; // inferred as `v is string`, not just `boolean`
}
const mixed: (string | number)[] = ["a", 1];
const onlyStrings = mixed.filter(inferredPredicate).map((s) => s.toUpperCase());
// => ["A"] — `.filter` narrowed the array's element type, so `.map` sees strings

// `!` is the NON-NULL ASSERTION: you promising there is no null here. No check is
// emitted, so if you are wrong you get the crash you were trying to prevent.
const definitely = (["a"] as string[] | undefined)!.length; // => 1

// And here is how narrowing gets LOST. It belongs to a position in the code, so
// ASSIGNING to the narrowed reference throws it away and the declared type comes
// back. Copy into a const first and the copy keeps what it knew (36.4).
function narrowingLost(v: string | number) {
  if (typeof v === "string") {
    const copy = v; // type: string — captured while the narrowing held
    v = 1; // this resets what the checker knows about `v`, from here down
    // @ts-expect-error TS2339: `v` is `string | number` again, and numbers have no length
    void v.length;
    return copy.length; // `copy` is a const: nothing can have changed it
  }
  return 0;
}
const survived = narrowingLost("four"); // => 4

// ─── 7. GENERICS ─────────────────────────────────────────────────────────────
//
// A type parameter is not a placeholder for "some type" — it is a way of saying
// that two places must AGREE. What goes in and what comes out are the same type,
// whatever that turns out to be. If a parameter appears only once, you probably
// wanted `unknown` instead.

function identity<T>(v: T): T {
  return v;
}
const inferredFromArgument = identity("from the argument"); // type: "from the argument"
const explicitArgument = identity<number>(1); // type: number — supplied, not inferred

function constrained<T extends { length: number }>(v: T) { // CONSTRAINT
  return v.length;
}
const stringLength = constrained("abc"); // => 3 — a string satisfies { length: number }
const arrayLength2 = constrained([1]); // => 1
// `constrained(1)` is TS2345: a number has no `length`.

function withDefault<T = string>(v?: T) { // TYPE PARAMETER DEFAULT
  return v;
}
const defaulted = withDefault(); // type: string | undefined — T fell back to its default

// The workhorse pattern: K is tied to T, so the return type follows the key you
// actually passed.
function keyOf<T extends object, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
const pickedString = keyOf({ a: 1, b: "s" }, "b"); // type: string, => "s"
const pickedNumber = keyOf({ a: 1, b: "s" }, "a"); // type: number, => 1

// `const T` moves the `as const` burden from every call site to the declaration.
function constParam<const T extends readonly unknown[]>(v: T): T {
  return v;
}
const preserved = constParam(["a", "b"]); // type: readonly ["a", "b"] — not string[]

// Overloads work inside classes and interfaces, by the same rules as file 02.
class Overloader {
  find(id: number): string;
  find(ids: number[]): string[];
  find(arg: number | number[]): string | string[] {
    return Array.isArray(arg) ? arg.map(String) : String(arg);
  }
}
const foundOne = new Overloader().find(1); // type: string, => "1"
const foundMany = new Overloader().find([1, 2]); // type: string[], => ["1", "2"]

// GENERIC CLASS, with a method-level type parameter of its own.
class Box<T> {
  private value: T;
  constructor(value: T) {
    this.value = value;
  }
  get(): T {
    return this.value;
  }
  map<U>(f: (v: T) => U): Box<U> {
    return new Box(f(this.value));
  }
}
const mappedBox = new Box(1).map(String).get(); // type: string, => "1"

// ─── 8. ASSERTIONS VS satisfies VS ANNOTATION ────────────────────────────────
//
// Three tools that look interchangeable and are not. Ask two questions of each:
// does it CHECK, and does it REPLACE the type you had?

type Palette = Record<string, [number, number, number] | string>;

// ANNOTATION — checks, and replaces. You now see the value through the wider type.
const annotatedPalette: Palette = { red: [255, 0, 0] };
const throughAnnotation = annotatedPalette.red;
// type: string | [number, number, number] | undefined — you have to narrow before you
// can index it, even though you wrote a tuple right there. The `| undefined` is the
// Record's index signature under noUncheckedIndexedAccess.

// ASSERTION — does not check at all. It overrules the checker and emits nothing,
// so when you are wrong nothing catches it until run time (40.1).
const assertedPalette = { red: [255, 0, 0] } as Palette;

// `satisfies` — checks, and KEEPS what you wrote. Usually the one you wanted.
const satisfiedPalette = { red: [255, 0, 0] } satisfies Palette;
const throughSatisfies = satisfiedPalette.red[0]; // type: number, => 255 — still known
// to be a tuple, which the annotation above threw away

// `as unknown as` for when the two types are not related at all. At least it is
// loud about what it is doing, which makes it greppable in review.
const forced = "string" as unknown as number; // type: number, and a string at run time

// ─── 9. BUILT-IN UTILITY TYPES ───────────────────────────────────────────────
//
// None of these are magic: every one is a mapped or conditional type you could
// write yourself, and file 09 shows how. Worth knowing by name so you do not
// rebuild them.

interface User { id: number; name: string; email?: string }

type P = Partial<User>; // every property optional
type R = Required<User>; // every property required — `email` loses its `?`
type RO = Readonly<User>; // every property readonly
type Pick_ = Pick<User, "id" | "name">; // keep the listed keys
type Omit_ = Omit<User, "email">; // drop the listed keys
type Rec = Record<string, User>; // build an object type from keys and a value type
type Excl = Exclude<"a" | "b", "a">; // type: "b" — remove union members
type Extr = Extract<"a" | "b", "a">; // type: "a" — keep union members
type NN = NonNullable<string | null>; // type: string
type Ret = ReturnType<() => string>; // type: string
type Params = Parameters<(a: string, b: number) => void>; // type: [a: string, b: number]
type Inst = InstanceType<typeof Box<number>>; // type: Box<number>
type Await = Awaited<Promise<Promise<string>>>; // type: string — unwraps recursively
type Upper = Uppercase<"abc">; // type: "ABC"
type NoInf = NoInfer<string>; // blocks a site from participating in inference

const picked: Pick_ = { id: 1, name: "n" }; // type: Pick_
const omitted: Omit_ = { id: 1, name: "n" }; // the same two keys, reached the other way