Skip to content
Chapter 4Lesson 1

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.

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.

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.

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
Booting type-checker…

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.

1 / 1

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.

Top of the lattice
any
unsound — accepts every value, propagates outward
unknown
sound — accepts every value, requires narrowing to read
Everyday surface
Concrete types
string, number, boolean,
User, Invoice, …
Bottom of the lattice
never
no inhabitants — uninhabited type
Outside the lattice
void
function-return marker,
not a position on the lattice
The four corners of the type system. `any` and `unknown` accept every value; `never` accepts none. `void` lives outside the lattice — it qualifies a function's return, not a value.

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.

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 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 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.

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.

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.

Primitive type The plain `string`, `number`, `boolean`, etc.
Literal union A finite, known set of values.
`unknown` Value from outside the type system.
`never` An impossible branch.
`void` A callback return the caller ignores.
Needs a brand A primitive that should not be assignable across entities.
HTTP method on a Request
Invoice status in the database
User-entered text in a search box
JSON parsed from a webhook payload
The return of an addEventListener handler
The default branch of a fully-narrowed switch
A result from a third-party SDK with no types
A 64-bit Stripe customer ID alongside a Stripe charge ID

The 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.