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 you reach for when typing everyday values.
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 every string, so 'pendng' passes both the type checker and review. Three months later the === 'paid' branch silently fails to fire, because the typo is still a valid string as far as the language is concerned.
const welcome = (payload: any) => { const email = payload.email.toLowerCase(); return `Welcome, ${email}`;};
welcome({ email: null });Typing payload as any disables type checking on every read off the value, so the compiler stays quiet. At runtime, the call throws Cannot read properties of null (reading 'toLowerCase') three callers up the stack.
Both bugs have the same shape. In each case the engineer reached for the widest type the language allows: string for a field that’s actually one of three values, and any for a payload that should have been treated as data from outside the type system. The compiler would have caught both bugs if the type vocabulary had been right.
This lesson teaches that vocabulary. By the end you’ll reach for a literal union when the runtime values are finite and known, reach for unknown instead of any at every boundary where a value arrives from outside, and recognize the trigger that earns never and the one that earns void. The primitives themselves are review, since you’ve written values in each shape across the three chapters before this one. What’s new is the literal-narrowing layer that sits on top of them, and the four corner types that sit outside the usual primitive surface.
The seven primitives, anchored to typeof
Section titled “The seven primitives, anchored to typeof”TypeScript’s primitive types share their names with the JavaScript primitives you already know. The one exception is null, which typeof reports as 'object' for the legacy reasons covered in the JavaScript value-model chapter.
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'Those seven names are the full primitive surface of the type system. Everything else you’ll type in this chapter either narrows one of them (literal types, in the next section), composes them (object shapes, tuples, and unions, in lessons later in this chapter), or sits at the four corners (later in this lesson). There’s no grand tour here, because you already know what each primitive is for.
Literal types: a primitive narrowed to one value
Section titled “Literal types: a primitive narrowed to one value”A literal type is a primitive type narrowed to exactly one value. 'pending' is a type whose only inhabitant is the string 'pending'. In the same way, 42 is a type whose only inhabitant is the number 42, and true is a type whose only inhabitant is true. At runtime, a literal-typed value looks identical to a primitive-typed one; the difference lives entirely at compile time.
const status: 'pending' = 'pending';const retries: 3 = 3;const isPaid: true = true;A literal type used alone is rarely useful, since a variable that can only ever hold 'pending' is just a const with extra ceremony. Literal types earn their weight when you compose several of them into a union.
Literal unions: the senior reach for finite domains
Section titled “Literal unions: the senior reach for finite domains”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. The side of an order is 'buy' | 'sell', not string. Every time you write string for a field whose values you could list in five seconds, you’ve handed the compiler less information than it needs to catch your typos.
Here’s the first bug fixed.
const setStatus = (status: string) => { invoice.status = status;};
setStatus('pendng');The annotation says any string is valid, and 'pendng' is a string. So the call compiles, the code ships, 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 now a compile error that points straight at the literal. The bug never reaches review, let alone production.
The payoff goes further than catching the typo. Every site that reads the value gets autocomplete for the three members: your editor offers 'draft', 'sent', and 'paid', and refuses to suggest anything else. Every site that produces a value gets a compile error for any string outside the set. And when you rename 'paid' to 'settled' six months from now, every call site that still uses the old literal turns into a compile error, so refactoring a closed domain becomes a search-and-replace the compiler verifies for you.
Make this change yourself before reading on. The habit sticks far better once you’ve done it by hand.
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
The fix is to change status: string to status: 'draft' | 'sent' | 'paid'. With the closed set named, 'pendng' is no longer assignable, the line below the directive errors as expected, and the @ts-expect-error itself stops complaining.
Where literal types come from
Section titled “Where literal types come from”Once you start reaching for literals, you’ll run into a surprise that catches most people early on: 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 lives a few lessons away.
const status: 'draft' = 'draft';const direct = 'draft';const tuple = ['draft', 'sent'] as const;const config = { status: 'draft' };Written annotation. The annotation itself names the literal type, so the variable’s type is exactly 'draft' with 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.) This is the const-binds-the-name rule from the value-model chapter doing useful work.
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, lifting an entire literal into its narrowest type. Here that’s a readonly ['draft', 'sent'] tuple of literals, not a string[]. The full treatment lands later in this chapter; for now you only need to know the form exists.
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 you put inside an object literal gets widened at inference to its base primitive, because TypeScript assumes the property might be reassigned later. This is the surprise from the start of the section; the fix is as const or satisfies, both later in this chapter.
The technical name for what’s happening on the last line is widening . You don’t need to work around it yet, only to recognize it. When you write a config object and the autocomplete shows string where you expected the literal, that’s widening, not a bug.
The four corners
Section titled “The four corners”Four types sit outside the usual primitive surface, at the edges of the type system. Each one has a single trigger that earns it, and that trigger is all you need to take from this section.
User, Invoice, …
not a position on the lattice
any: the unsound escape
Section titled “any: the unsound escape”any disables type checking on a value, and that effect spreads outward. Reading .email on an any payload compiles, calling it as a function compiles, and passing it as a number argument compiles. The compiler simply stops checking. That’s why the second opening bug shipped: payload.email.toLowerCase() compiled cleanly even though the runtime value was null.
The course never writes any. If a third-party library forces an any into your code, you accept the value as unknown and narrow it before doing anything with it.
const welcome = (payload: any) => { const email = payload.email.toLowerCase(); return `Welcome, ${email}`;};
welcome({ email: null });any silences the type 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 it refuses to let you read off it without narrowing first. The compile error points at the exact bug the runtime would have hit. The narrowing lives in the next subsection.
Biome, the linter the course pins in its strict baseline a few chapters from now, has a noExplicitAny rule that catches any annotations automatically. Once the toolchain is in place, you won’t need to catch them by hand.
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 a third-party SDK that ships without types. Anywhere a value crosses into your code from a place TypeScript can’t see, unknown is the type that should greet it.
The trigger is the friction itself: you cannot read or call an unknown value without narrowing it first. That restriction is the whole point, because it forces every assumption about the value’s shape to become 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;};There are three checks before the read: the value is an object, it isn’t null (the null check is needed because typeof null === 'object', the legacy quirk from the JavaScript value-model chapter), and the property 'email' exists on it. The read then produces another unknown, so a final typeof check pins it to string before returning. Every assumption about the shape is visible in the code, which means an unexpected wire shape can’t slip a runtime crash through.
Narrowing gets its own lesson later in this chapter. For now, notice the posture: the verbosity is what enforces the contract. In production, a Zod schema parse at the wire boundary (once Zod lands a few chapters from now) replaces this hand-rolled narrowing with a structured version, but the underlying posture is identical. Every Zod schema is a more legible form of the same checks you see here.
never: the bottom
Section titled “never: the bottom”never is the type with no inhabitants. No runtime value satisfies it. It shows up in two places: as the return type of a function that always throws (it never returns a value), and as the type of a variable in a branch of code that the language 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 time control reaches the last return status line, every member of the union has already been handled. The only inhabitants of Status were the three literals, and they’re all accounted for, so TypeScript narrows status to never at that line. That never is exactly what the exhaustiveness pattern in the next chapter relies on to turn “I forgot to handle a case” into a compile error. You don’t need to write that pattern yet, only to know that never is what makes it work.
void: function-return-only
Section titled “void: function-return-only”void means “the caller should not rely on the return value of this function.” It’s a marker, not a value. It’s also distinct from undefined in a way that causes confusion if you blur the two: undefined is a value the caller can read, while void is a contract that says to ignore whatever the function returns.
The canonical site is a callback whose return the framework throws away.
const on = (event: string, handler: () => void): void => { // the framework discards the handler's return value};
on('click', () => 42);The callback typed as () => void returns 42, and that’s fine. The contract says the caller will discard the return either way, so the callback is free to return anything it likes. You’ll meet this pattern at every browser event handler, every Array.prototype.forEach callback, and every fire-and-forget custom hook callback.
One trap to avoid: don’t write Promise<void> to mean “an async function that resolves with no payload.” That’s what Promise<undefined> is for, though more often you should simply let the inferred type carry it through. void on a Promise means “discard whatever this promise resolves to,” which is rarely what you actually mean.
Branded primitives and the enum non-reach
Section titled “Branded primitives and the enum non-reach”This lesson deliberately leaves out two moves, named here so you know the gaps exist.
Branded primitives. A string that represents a user ID and a string that represents an org ID are the same type to the compiler, so getUser(orgId) compiles. A bug like that can expose one customer’s data to another. The fix is a brand, written with TypeScript’s unique symbol form, that makes the two strings distinct types even though they’re identical at runtime. That pattern earns a full lesson in the next chapter; for now, recognize the gap and know there’s a name for the move.
The enum we don’t write. TypeScript ships an enum keyword, but the course doesn’t use it. Literal unions cover every case more cleanly, for three reasons. They integrate with JSON, whereas an enum member’s value isn’t its name, which causes friction at the wire. They don’t emit runtime code, whereas an enum compiles to a runtime object that ships in your bundle. And they read as plain values, whereas enum members read as references. You’ll still meet enum in legacy codebases, so recognize the form, but write a literal union when you author fresh code.
Sort the vocabulary
Section titled “Sort the vocabulary”Here are eight values, each drawn from a real spot in a SaaS codebase. Decide which corner of the new vocabulary each one belongs in. The same primitive shows up in multiple categories, so the right call depends on where the value comes from and what it represents.
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 whole posture of this chapter. The next lesson asks the same question about object shapes, and the rest of the chapter asks it about tuples, dynamic keys, unions, narrowing, and the literal-preserving idiom that ties the vocabulary together. The vocabulary you learned today is the floor everything else builds on.
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.