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 laterA 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:
anyopts out of checking entirely, and it is infectious — anything touched by it stops being checked.unknownis the top type: everything is assignable to it, and you can do nothing with it until you narrow. It is whatanyshould have been.neveris the bottom type: no value is assignable to it. It means “this cannot happen”, which is what makes exhaustiveness checks work.voidmeans “the caller will not look at this”, which is subtly different from returning nothing — see the function section below.
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:
- Method syntax (
m(a: string): void) has bivariant parameters; property syntax (m: (a: string) => void) is contravariant understrictFunctionTypes. The same member written two ways is checked two different ways. File 37 explains why the exception exists. - An index signature is a promise about every key, not a fallback for the ones you did not list.
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:
noUncheckedIndexedAccessmakes indexing honest —arr[10]really might not be there, so you get| undefinedand have to deal with it.exactOptionalPropertyTypesmakes?honest — without it, “optional” quietly also means “may be present and explicitlyundefined”, a distinction the runtime genuinely makes.
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:
typeoffor primitives,instanceoffor classes,Array.isArrayfor arrays;- the
inoperator, for telling object shapes apart; - equality against a literal, and
== null, which catches bothnullandundefinedin one check; - truthiness, using file 01’s falsy list — which is
why narrowing on a number removes
0and narrowing on a string removes""; - a type predicate (
v is string[]) you write yourself, or one the checker infers from an obvious body since TypeScript 5.5; - an assertion signature
(
asserts v is T), which narrows everything after the call.
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.
- A constraint
(
T extends { length: number }) limits whatTmay be and tells the checker what you may do with it. K extends keyof Twith a return ofT[K]is the workhorse pattern: the key and the result stay tied to the object.- A default (
T = string) applies when inference has nothing to work from. const Tmoves theas constburden from every call site to the declaration.
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
- For each declaration, state the inferred type and whether it widened.
- For each union, name the check that narrows it and the branch where that applies.
- Tell type-space syntax apart from value-space syntax, even when the words match.
- For each generic, identify what relationship it is preserving.
- Distinguish annotation, assertion,
as const, andsatisfiesby what each one checks.
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