TypeScript

Literal types and const assertions

TypeScript can type a value as not just "a string" but the exact string "pending" — and knowing exactly when that narrower type kicks in, and when it silently widens back to plain string, is what makes literal types actually usable.

Intermediate

4 min read

A literal type is a type with exactly one possible value

let status: "pending" = "pending";
status = "confirmed"; // Error: Type '"confirmed"' is not assignable to type '"pending"'

"pending" here isn't just the value "pending" — it's also a type, one that only that exact string satisfies. This looks like a strange, overly-narrow thing to want on its own, but literal types become genuinely useful the moment several of them are combined into a union — "pending" | "confirmed" | "cancelled" is exactly how the union-types lesson expressed "one of these three specific strings," built entirely out of individual literal types.

The widening problem: let quietly loses the literal type

let status = "pending";        // inferred as: string (widened!) — not "pending"
const status2 = "pending";      // inferred as: "pending" (the literal type, preserved)
 
function setStatus(s: "pending" | "confirmed") { }
setStatus(status);   // Error — string is not assignable to "pending" | "confirmed"
setStatus(status2);  // fine — status2 is the literal type "pending"

This is the sharp edge nearly everyone hits first: TypeScript infers let status = "pending" as the general string type, not the literal "pending" — because let means the variable could be reassigned to any other string later, so TypeScript widens to the type that actually reflects what's really possible. const, by contrast, can never be reassigned, so TypeScript keeps the narrow literal type inferred from the single value it will ever hold.

Widening inside object literals: the part that actually surprises people

function setStatus(s: "pending" | "confirmed") { }
 
const config = { status: "pending" }; // config.status inferred as: string, not "pending"!
setStatus(config.status); // Error — even though config is a const
 
setStatus(config.status as "pending"); // works, but silences a real check

This is the genuinely confusing case: config itself is a const, but the property status inside the object literal still widens to stringconst only prevents reassigning config itself, it says nothing about the mutability of config's own properties, so TypeScript can't assume config.status will stay "pending" forever the same way it can for a bare const variable. This is exactly the situation as const (below) exists to fix properly, without reaching for a type assertion that silences the check instead of actually solving it.

as const: locking a value's type down to its most literal form

const config = { status: "pending" } as const;
// config's type is now: { readonly status: "pending" }
 
setStatus(config.status); // works — config.status really is the literal type "pending" now
 
const methods = ["GET", "POST"] as const;
// methods' type is: readonly ["GET", "POST"] — a readonly tuple of literals,
// not string[] (which "GET"/"POST" would widen to without as const)

as const tells TypeScript: "infer the narrowest possible type for this value, and treat every property as readonly." It's the direct, deliberate fix for exactly the object-literal-widening problem above — every property keeps its specific literal type instead of widening to the general type, and the whole structure becomes immutable at the type level (attempting config.status = "confirmed" afterward is a compile error, readonly is enforced).

Where this shows up constantly: deriving a union from a list of values

const ROLES = ["admin", "editor", "viewer"] as const;
type Role = typeof ROLES[number]; // "admin" | "editor" | "viewer"
 
function checkRole(role: Role) { }
checkRole("admin");   // fine
checkRole("owner");    // Error: Argument of type '"owner"' is not assignable to type 'Role'

This is one of the most common real uses of as const: define the actual list of valid values once, as data (useful for iterating over at runtime — rendering a dropdown, say), then derive a matching union type from that same array with typeof ROLES[number], instead of writing the union out separately and risking the two falling out of sync. Without as const, ROLES would widen to string[], and typeof ROLES[number] would just be string — losing all the specificity that made this worth doing.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. Why does `let status = "pending";` get inferred as `string`, while `const status = "pending";` gets inferred as the literal type `"pending"`?

2. Given `const config = { status: "pending" };`, why does `config.status` widen to `string` even though `config` itself is a const?

3. What does `as const` actually do to a value's inferred type?