Lesson 7 · Module 1.2 — Core type vocabulary

Literal types, unions, and why a string-literal union usually beats enum

A classic example keeps a value as the type "click" — not string — and returns a union like "INFO" | "DEBUG" | "ERROR". That is the single most useful idea in everyday TS: a value can be its own narrow type, and a | joins those narrow types into a closed set. Learn to read it, and most “magic” unions stop being magic.

A literal type is a single exact value

Consider this: when you pass "click", TypeScript can keep the type as the exact literal "click", not the wide type string:

function test1<T extends string>(value: T): T { return value; }
function test2(value: string): string { return value; }

const r1 = test1("hello");   // r1 type: "hello"   ← the exact literal, kept
const r2 = test2("hello");   // r2 type: string    ← widened, specificity lost

But what does "hello" as a type actually mean? Think of a type as the set of values it allows. string is the set of every possible piece of text — "a", "hello", "anything at all", endlessly. The literal type "hello" is the set with exactly one allowed value: the text "hello" and nothing else. So a variable typed "hello" can only ever hold "hello":

let s: string  = "hello";   // ok — string allows any text…
s = "world";                // …so reassigning to another string is fine

let h: "hello" = "hello";   // ok — the only value this type permits
h = "world";                // ERROR — "world" is not "hello"

Numbers work exactly the same way: number means “any number,” while the literal type 42 means “must be exactly 42.” So "hello" sits inside string just the way 42 sits inside number — one pinpoint value carved out of the wide, general base type. That is what “a literal type is a single exact value” means.

So why does r1 stay "hello" while r2 falls back to string? It turns on a default rule called widening, and on where each function catches the value:

test2(value: string)test1<T extends string>
what catches the valuea fixed string slotthe type parameter T
what happens to "hello"widens to string at the doorkept as the literal "hello"
so the return isstringT = "hello"

A bare literal like "hello" is a fresh literal type that widens to its base type (string) by default — so the instant it lands in a plain string parameter, its exact identity is gone, and the declared return string is the best test2 can give back. To keep it, something must pin the literal: a const, a literal-typed position, or a generic constrained with <T extends string>. The pivot is the constraint, not merely “being generic” — even id<T>(x: T): T hands id("hello") back as string, because an unconstrained T pins nothing.

A union | is a closed set of allowed values

A common pattern returns a union of literals from an array:

const makeStatus = <T extends string>(statuses: T[]) => statuses;
const s = makeStatus(["INFO", "DEBUG", "ERROR", "WARNING"]);
// s:  ("INFO" | "DEBUG" | "ERROR" | "WARNING")[]   ← literal union preserved

Read "INFO" | "DEBUG" | "ERROR" | "WARNING" as “any one of exactly these four strings, and nothing else.” That closed set is what gives you autocomplete and the compiler error when you mistype "DEUBG". The extends string constraint is what tells TS to keep the literals instead of collapsing them to string — that is the whole trick worth remembering.

Why a string-literal union usually beats enum

An enum and a string-literal union both model “one of a fixed set,” but they read very differently:

string-literal unionenum
declarationtype Dir = "up" | "down"enum Dir { Up, Down }
exists at runtime?no — pure type, erasedyes — emits a real object
the value you passthe plain string "up"Dir.Up (must import the enum)
narrowing in a switchworks directly on the stringworks, but couples you to the enum

The union is just data + a type. The enum is a value and a type that survives compilation, so it adds runtime weight and forces every caller to import it. For reading code: type X = "a" | "b" means “pass the bare string”; enum X means “there is a real X object you must reach through.” Most modern code prefers the union for exactly that lightness.

The difference you can see is what each one compiles to. JavaScript has no enum, so TypeScript has to fabricate one: every enum becomes a real object, wired up at runtime and shipped in your bundle. A union is only a type, so it compiles to nothing.

// —— what you write ——
enum PackStatus { Draft = "Draft", Shipped = "Shipped" }
type PackStatus2 = "Draft" | "Shipped";

// —— what JavaScript gets ——
var PackStatus;
(function (PackStatus) {
  PackStatus["Draft"]   = "Draft";
  PackStatus["Shipped"] = "Shipped";
})(PackStatus || (PackStatus = {}));   // ← the enum: a real runtime object
// PackStatus2 → gone. Erased. Zero bytes.

That single fact explains the rest. Because the enum is a real object you must import it everywhere just to write PackStatus.Draft, and it adds (small, but real) weight to every bundle. Numeric enums are looser still: TypeScript also emits a reverse map — so Object.keys returns 6 entries, not 3 — and it will quietly accept a bare logStatus(0) where the enum is expected. A union has none of this: you write the plain string "Draft" with full autocomplete, and there is nothing to import or ship. It is also where the ecosystem is heading — TypeScript 5.8’s erasableSyntaxOnly flag and Node’s built-in type-stripping both reject enum precisely because it emits runtime code, keeping to the rule “TypeScript is JavaScript with types.”

The one thing a union genuinely can’t do is be listed at runtime — it is a type, so it is gone once the program runs. When you need both the runtime list and the type, declare the values once as a const array and derive the union from it:

const statuses = ["Draft", "Shipped"] as const;
type PackStatus = typeof statuses[number];   // "Draft" | "Shipped"

// statuses → iterable JS data;  PackStatus → the matching union. Best of both worlds.

Why the as const? The binding is already const, but the array’s contents are still mutable (you could statuses.push("Archived")), so TypeScript widens it to string[] — the literals fall back to string and the positions are forgotten, leaving typeof statuses[number] as just string. as const freezes the array into a readonly tuple readonly ["Draft", "Shipped"]; now nothing can change, so TS safely keeps each literal, and [number] reads them back as "Draft" | "Shipped". It is the same widening from the top of this lesson — as const is simply its pin for arrays and objects, the way <T extends string> was the pin for a function argument.

Now you can iterate (statuses.map(…)) and get a type that can never drift from the list. Lesson 1.3 unpacks the typeof statuses[number] half of the trick; Lesson 2.2 covers as const in full.

Read it this way

See a | between string literals? Read “a closed menu of allowed values.” See extends string on a generic? Read “keep the exact literals the caller passed, don’t widen to string.” Those two readings cover almost every union you’ll meet.

Check yourself

Recall, don’t re-read. (Answers reveal on click.)

In const r1 = test1("hello") with test1<T extends string>, r1 is typed…
How should you read "INFO" | "DEBUG" | "ERROR" in type position?
A key difference when reading an enum vs a string-literal union is that the enum…

Primary source

The handbook page for both ideas is Everyday Types — Literal Types (and Unions just above it). For the trade-offs that push people away from enum, see Enums — Objects vs Enums. Best way to feel it: paste test1 and test2 into the TS Playground and hover r1 vs r2.

Try it yourself

Got a union you can’t parse? Read it closely and ask “closed set or open type?” Next lesson (1.3): keyof, the two typeofs, and indexed access T[K] — how a union of keys gets built from a real object.