TypeScript from the ground up

Exact values

A type can name one exact value: a set of allowed strings instead of a loose one, and `as const` for the lists and objects that should never be edited by accident.

Part 8 of 13

Intermediate14 min readBack to Courses

A type can be one value

A string is any text. "draft" is exactly that word, and TypeScript treats the two as different types — a literal type names one value, not a category. Put a few in a union and you have a set: "draft" | "review" | "published".

This is the honest type for anything with a fixed menu of choices: a status, a size, a direction. Spelling one wrong is caught at the line that spelled it, instead of being discovered by a user. The compiler checks the set the same way it checks any union: each use has to be one of the named members.

A set of allowed values, and a frozen list

type Status = "draft" | "review" | "published";

function label(status: Status): string {
  return status === "published" ? "Live" : "Not live";
}

const SIZES = ["small", "medium", "large"] as const;
type Size = (typeof SIZES)[number];   // "small" | "medium" | "large"

What as const does

Without it, a list is a string[] — any text may be added, and the compiler cannot know the menu. as const freezes the value into exactly what is written: the element type becomes the union of the literals, and the list itself cannot be changed.

The (typeof SIZES)[number] line reads as "the type of the items in SIZES". It is a mouthful, and it is the standard way to keep one source of truth: the list of values and the type of those values cannot drift apart.

Unions, not enums

TypeScript has an enum keyword, and you will meet it in older code. Prefer the union: it is plain TypeScript, it needs no run-time object, and it works everywhere a string works — in JSON, in a URL, in a form value.

An enum generates code; a union generates nothing. That is the whole argument, and it is why modern codebases reach for the union.

Where an exact type meets the outside world

A literal union is exact inside your program. A value read from a form is still string, no matter what the menu said, so it cannot be assigned to Status without a check. That is not a flaw: it is the compiler refusing to believe a value just because you hope.

The usual answer is a guard or a lookup: check that the string is one of the three, and only then treat it as a status. A cast (value as Status) skips the check and moves the mistake to the first place the wrong value is used.

as const reaches into the value

On an array, as const makes it readonly and turns each element into its own literal type. On an object it does the same to every property, which is why typeof config can be used as a precise type for a settings table.

The frozen type is only as useful as it is honest. If the value really can change, as const is a promise the compiler will hold you to; use it for menus, keys and configuration that are written once.

When a literal keeps its value

Not every "draft" you write stays a literal. A const keeps it, because nothing can reassign the name; a let widens it to string, because the next assignment might be something else. The compiler is not being inconsistent — it is asking how much it can prove about the value over the whole life of the variable.

Function parameters widen too. A parameter declared status = "draft" has the type string, not "draft", unless you annotate it. The rule to remember: a literal survives where the compiler can see the value cannot change, and it widens everywhere else.

What goes wrong

The first symptom is a value that should fit and does not: const status: Status = "Draft"; fails on the capital D, and the error lists the three allowed strings. That list is the useful part of the message; compare it with what you wrote.

An unintended widening is the second: pass "draft" to a function that takes string, then try to use the result as a Status. Once widened, the literal information is gone, and the fix is to keep it — annotate the parameter or pass a value the compiler can see is fixed.

Three ways exact types go wrong. A let status = "draft" widens to string, because a later assignment could change it — use const, or annotate the union. A cast to the union is not a check. And as const makes the whole literal readonly, including nested objects, so do not reach for it on data you still need to edit.

Try these next

  • Add a fourth status and follow the compiler errors to every place that needs it.
  • Write a guard that accepts only the three statuses from a plain string.
  • Build a Record<Size, number> price table and add a new size to the list.

An exact set of statuses

Declare a Status type with exactly `draft`, `review` and `published`, then type the parameter with it so only those three are accepted.

Live Preview
Console output will appear here...

3 left to do

  • A named union describes the states
  • The union lists all three values
  • The parameter accepts only those values

Literal types join with the pipe: type Status = "draft" | "review" | "published"; — then status: Status is enough on the function.

A size chart from one list

Freeze the list of sizes with `as const`, derive the `Size` type from it, and use that type for the price table.

Live Preview
Console output will appear here...

3 left to do

  • The list is frozen with as const
  • The size type is derived from the list
  • The prices are keyed by the size type

const SIZES = ["small", "medium", "large"] as const; then type Size = (typeof SIZES)[number]. The record keys must cover exactly those three.

When the same three strings appear in four files, the type is the place to say them once. A literal union turns a typo in a menu of choices into a compile-time error.

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

What is the type "draft" in TypeScript?