Lesson 7 · Module 1.2 — Core type vocabulary
enumA 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.
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 value | a fixed string slot | the type parameter T |
what happens to "hello" | widens to string at the door | kept as the literal "hello" |
| so the return is | string | T = "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.
| is a closed set of allowed valuesA 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.
enumAn enum and a string-literal union both model “one of a fixed set,” but they read very
differently:
| string-literal union | enum | |
|---|---|---|
| declaration | type Dir = "up" | "down" | enum Dir { Up, Down } |
| exists at runtime? | no — pure type, erased | yes — emits a real object |
| the value you pass | the plain string "up" | Dir.Up (must import the enum) |
narrowing in a switch | works directly on the string | works, 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.
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.
Recall, don’t re-read. (Answers reveal on click.)
const r1 = test1("hello") with test1<T extends string>, r1 is typed…"INFO" | "DEBUG" | "ERROR" in type position?enum vs a string-literal union is that the enum…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.
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.