Primitives, literals, and the four corners
Your first TypeScript lesson, the vocabulary of primitive types, literal unions, and the any, unknown, never, and void corners.
Here are two production bugs that share a single root cause.
const setStatus = (status: string) => { invoice.status = status;};
setStatus('pendng');
if (invoice.status === 'paid') { sendReceipt(invoice);}The function accepts any string, so 'pendng' clears both the type checker and review. Months later the === 'paid' branch silently never fires.
const welcome = (payload: any) => { const email = payload.email.toLowerCase(); return `Welcome, ${email}`;};
welcome({ email: null });Typing payload as any turns off type checking for every property read, so the compiler stays silent. At runtime the call throws Cannot read properties of null (reading 'toLowerCase'), three callers up the stack.
In both, the engineer reached for the widest type the language allows: string for a field that holds one of three values, and any for a payload that arrives from outside the type system. A precise type would have let the compiler catch each bug.
This lesson teaches that vocabulary: a literal union for a finite, known set of values, unknown instead of any at every boundary, and the triggers for never and void. The primitives themselves are review; what’s new is the literal layer on top of them and the four corner types that sit outside the usual primitives.
The seven primitives and typeof
Section titled “The seven primitives and typeof”TypeScript’s primitive types reuse the names of the JavaScript primitives you already know. The exception is null, which typeof reports as 'object'.
const name = 'Lina'; // string, typeof 'string'const amount = 4900; // number, typeof 'number'const isPaid = true; // boolean, typeof 'boolean'const tenantId = 9007199254740993n; // bigint, typeof 'bigint'const slot = Symbol('slot'); // symbol, typeof 'symbol'const next = null; // null, typeof 'object' (legacy)const missing = undefined; // undefined, typeof 'undefined'These seven names are the full primitive surface of the type system. Everything else in this chapter either narrows one of them (literal types), composes them (object shapes, tuples, and unions), or sits at the four corners. You already know what each primitive is for, so the list is a review.
Literal types: a primitive narrowed to one value
Section titled “Literal types: a primitive narrowed to one value”A literal type is a primitive narrowed to exactly one value: 'pending' is a type whose only inhabitant is the string 'pending'. At runtime such a value is indistinguishable from a plain primitive; the difference lives entirely at compile time.
const status: 'pending' = 'pending';const retries: 3 = 3;const isPaid: true = true;A literal type alone is rarely useful: a variable that can only hold 'pending' is just a const with extra ceremony. Literal types earn their weight when you compose several into a union.
Literal unions: a type for a finite, known set of values
Section titled “Literal unions: a type for a finite, known set of values”The rule is simple: if the runtime values are finite and known at design time, the type is a literal union, not the primitive. An invoice status is one of 'draft' | 'sent' | 'paid', not string. An HTTP method is one of 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', not string. An order’s side is 'buy' | 'sell', not string. Whenever you write string for a field whose values you could list in five seconds, you give the compiler too little to catch your typos.
Here is the intro’s typo bug, before and after.
const setStatus = (status: string) => { invoice.status = status;};
setStatus('pendng');The annotation accepts any string, and 'pendng' is a string, so the call compiles and the downstream === 'paid' check silently fails forever.
const setStatus = (status: 'draft' | 'sent' | 'paid') => { invoice.status = status;};
setStatus('pendng');// Error: Argument of type '"pendng"' is not assignable to// parameter of type '"draft" | "sent" | "paid"'.The annotation names the closed set, so the typo is a compile error that points straight at the literal, caught before review.
The payoff goes beyond this one typo. Every site that reads or writes the value gets autocomplete for the three members and a compile error for anything outside the set. And when you rename 'paid' to 'settled' later, every call site still using the old literal becomes a compile error, turning a refactor of the closed domain into a search-and-replace the compiler verifies for you.
Make this change yourself before reading on.
Replace the string annotation on status with a literal union of the three valid invoice statuses ('draft', 'sent', 'paid') so the typo call below becomes a compile error. The @ts-expect-error directive on the line above asserts the next line should fail — make it fail, and the directive's own error goes away.
- Fix all errors
Where literals are preserved, and where they widen
Section titled “Where literals are preserved, and where they widen”Reach for literals and you hit a surprise: const config = { status: 'draft' } doesn’t preserve the literal 'draft'. TypeScript infers { status: string } instead. The reason is worth knowing now, even though the fix lands later in this chapter.
const status: 'draft' = 'draft';const direct = 'draft';const tuple = ['draft', 'sent'] as const;const config = { status: 'draft' };Written annotation. The annotation names the literal type itself, so the variable is exactly 'draft', no inference involved. This is the most explicit form, and the one you reach for in function parameters and exported types.
const status: 'draft' = 'draft';const direct = 'draft';const tuple = ['draft', 'sent'] as const;const config = { status: 'draft' };const binding on a primitive. A const bound directly to a primitive literal infers the literal type, so direct is 'draft', not string. Swap const for let and the type widens to string, because the variable could then be reassigned to any string.
const status: 'draft' = 'draft';const direct = 'draft';const tuple = ['draft', 'sent'] as const;const config = { status: 'draft' };as const. This freezes the value at the value site, giving it its narrowest type. Here that’s a readonly ['draft', 'sent'] tuple of literals, not a string[].
const status: 'draft' = 'draft';const direct = 'draft';const tuple = ['draft', 'sent'] as const;const config = { status: 'draft' };The non-source. Object properties widen, so config.status is string, not 'draft'. Every literal inside an object literal widens to its base primitive at inference, because TypeScript assumes the property might be reassigned.
The technical name for what happens on the last line is widening . You only need to recognize it for now: when autocomplete on a config object shows string where you expected the literal, that’s widening.
The four corners: any, unknown, never, and void
Section titled “The four corners: any, unknown, never, and void”Four types sit at the edges of the type system. Each one earns its place by a single trigger.
User, Invoice, …
not a position on the lattice
any: the unsound escape
Section titled “any: the unsound escape”any is unsound : it turns off type checking on a value, and the effect spreads. Reading .email on an any payload compiles, calling it as a function compiles, and passing it as a number compiles. That is why the second opening bug shipped: payload.email.toLowerCase() compiled cleanly even though the runtime value was null.
The course never writes any; the linter we adopt later flags it for you. When a third-party library forces an any into your code, accept the value as unknown and narrow it before using it.
const welcome = (payload: any) => { const email = payload.email.toLowerCase(); return `Welcome, ${email}`;};
welcome({ email: null });any silences the checker on every read, so the compiler stays quiet. The runtime crashes the moment email is null.
const welcome = (payload: unknown) => { const email = payload.email.toLowerCase(); // Error: 'payload' is of type 'unknown'. return `Welcome, ${email}`;};unknown accepts every value too, but refuses to let you read off it without narrowing first. The compile error points at the exact bug the runtime would have hit.
unknown: the sound top
Section titled “unknown: the sound top”unknown is the right type for any value that arrived from outside the type system: JSON parsed from the wire, a read from localStorage, the bound parameter of a catch clause, or the return of an untyped SDK. Wherever a value crosses into your code from a place TypeScript can’t see, type it unknown.
The trigger is the friction itself: you cannot read or call an unknown value without narrowing it first, so every assumption about its shape becomes code the compiler can see.
const readEmail = (payload: unknown): string | null => { if (typeof payload === 'object' && payload !== null && 'email' in payload) { const email = payload.email; return typeof email === 'string' ? email : null; } return null;};Three checks guard the read: the value is an object, it isn’t null (needed because typeof null === 'object'), and it has an 'email' property. The property is itself unknown, so a final typeof pins it to string before returning. With every assumption spelled out, an unexpected wire shape can’t slip a crash through.
Narrowing gets its own lesson later in this chapter. For now, notice that the verbosity is what enforces the contract; a Zod schema will later replace these hand-rolled checks with the same posture in structured form.
never: the bottom
Section titled “never: the bottom”never is the type with no inhabitants: no value is a never. It appears in two places: as the return type of a function that always throws, and as the type of a value in a branch the compiler has fully narrowed away.
const fail = (message: string): never => { throw new Error(message);};
type Status = 'draft' | 'sent' | 'paid';
const statusLabel = (status: Status): string => { if (status === 'draft') return 'Draft'; if (status === 'sent') return 'Sent'; if (status === 'paid') return 'Paid'; return status;};By the last return status, the three literals of Status are all handled, so TypeScript narrows status to never. That never is what the exhaustiveness pattern in the next chapter uses to turn a forgotten case into a compile error.
void: ignore the return value
Section titled “void: ignore the return value”void means the caller should ignore this function’s return value. It is distinct from undefined: undefined is a value the caller can read, while void is a contract to ignore whatever comes back.
The canonical site is a callback whose return value the caller discards.
const on = (event: string, handler: () => void): void => { // the framework discards the handler's return value};
on('click', () => 42);The handler is typed () => void yet returns 42, and that compiles: since the caller discards the return, the callback may return anything. You will meet this at every browser event handler and every Array.prototype.forEach callback.
One trap: don’t write Promise<void> to mean “an async function that resolves with nothing.” On a Promise, void means “discard whatever this resolves to,” which is rarely what you want; reach for Promise<undefined> instead.
Literal unions instead of enum
Section titled “Literal unions instead of enum”One related gap is worth naming: a user ID and an org ID are both string to the compiler, so getUser(orgId) compiles and can leak one customer’s data to another. A brand makes the two distinct types; that pattern gets its own lesson next chapter.
TypeScript ships an enum keyword, but the course doesn’t use it. Literal unions cover every case more cleanly: they round-trip through JSON, they emit no runtime code (an enum compiles to an object that ships in your bundle), and they read as plain values rather than references. You’ll still meet enum in legacy code, so recognize the form, but reach for a literal union in fresh code.
Sort the vocabulary
Section titled “Sort the vocabulary”Here are eight values, each drawn from a real codebase. Decide which corner of the new vocabulary each one belongs in.
Pick the senior reach for each value. The same primitive (`string`) shows up in three different categories — the right call depends on where the value comes from and what it represents. Drag each item into the bucket it belongs to, then press Check.
addEventListener handlerswitchresult from a third-party SDK with no typesThe shift from “is this a string?” to “what does the string represent?” is the posture of this whole chapter. The next lesson asks the same question about object shapes.
External resources
Section titled “External resources”The primitive surface, literal types, and the any / unknown corners: the official reference for everything in this lesson.
The reference you'll return to when narrowing lands later in this chapter. The hand-rolled `unknown` example here is its opening section.
Matt Pocock's free book chapter covering union types, literal types, unknown, never, and discriminated unions: the whole shape of this lesson plus the next one.
Iván Ovejero's deep dive on types-as-sets: the mental model behind the four-corners lattice, with diagrams of union, intersection, and the never / unknown bookends.