A literal type restricts a type to one exact value; unions of literals directly model protocol methods, states, and other closed choices.
Literals in mutable positions usually widen to string, number, or boolean, while an assertion can hide that runtime input isn’t a member of the union at all.
Use annotations to declare contracts, as const to preserve constant data, satisfies to check shapes, and runtime validation before external data enters a union.
What it is and why it exists
A literal type contains one exact value, such as the string "queued", the number 404, the bigint 10n, or the Boolean value true.
string represents every string, while "queued" represents only that one string.
This precision lets the compiler decide whether a value really belongs to a finite protocol, rather than merely sharing its primitive type.
Production code usually combines several literals in a union type , such as "GET" | "POST".
That type describes a closed set: a value may be "GET" or "POST", but not any other string.
String literals are the most common form; number, bigint, and Boolean literals follow the same assignability rule.
Literal types answer “which exact values are allowed here,” not just “which primitive kind is stored here.” That difference matters when a business rule has a few stable choices, such as job states, command names, log levels, retry counts, or response tags. A misspelled state is rejected during compilation, and the editor can offer completions from the union.
Literal fields can also connect an object’s tag to its payload.
If the member with status: "complete" always has a url, while status: "failed" always has a message, checking status makes the matching field safe to use.
This structure is a discriminated union .
A literal type isn’t an input validator, and its information doesn’t survive in the emitted JavaScript.
Network responses, JSON, environment variables, and ordinary JavaScript callers can still provide values outside the set.
Those values should enter the boundary as unknown or a broad runtime type and receive the literal union only after real checks pass.
If the allowed values are naturally open, such as usernames or arbitrary URLs, keep using string.
Turning every example value into a union misrepresents data as a closed protocol and makes routine expansion require pointless type edits.
Literal types fit finite, enumerable choices maintained by the program.
How it works
The type checker treats a type as a set of possible values.
A literal type is a one-element set, and the union operator | combines sets.
Therefore, type Speed = "standard" | "express" contains exactly two strings, and a Speed is still assignable where any string is accepted.
The reverse assignment isn’t safe.
An ordinary string might be "overnight" at runtime, so it can’t be assigned to a parameter that accepts only Speed.
Assignability depends on whether every possible source value belongs to the target set, not whether the current runtime value happens to look right.
Values, types, and assignability
The expression "express" produces a JavaScript string and gives the checker exact literal information.
The annotation const speed: Speed = "express" checks that the initializer belongs to the union and fixes the variable’s public contract as Speed.
If the union changes later, every site that depends on that contract is checked again.
A literal union has no runtime container.
Unlike a regular enum, it emits no object to inspect, and you can’t enumerate it with Object.values(Speed) because Speed exists only in a type position.
When you also need a runtime option list, derive the union from an as const tuple so the value and type share one source.
The Boolean type can be understood as true | false.
The individual type true can express a satisfied condition or a generic branch result, but an ordinary feature flag should usually remain boolean.
Number literals suit a few domain-specific codes or levels; they don’t describe an arbitrary integer range.
null and undefined also have one runtime value each and often appear in unions.
They mainly model absence; this topic focuses on literals produced by string, number, bigint, and Boolean expressions that participate in widening.
Inference and literal widening
Type inference gathers information from initializers, call arguments, and the surrounding expected type.
The checker has to balance precision against later mutation.
An immutable primitive const can stay exact, while a let usually needs to admit other values of the same primitive kind.
For example, const mode = "dark" usually has type "dark" because the variable binding can’t change to another value.
let mode = "dark" usually has type string; otherwise, a valid next line such as mode = "light" would be blocked by the initializer.
The move from an exact literal to a broader primitive is literal widening .
const fixes only the variable binding; it doesn’t make object properties readonly.
With const request = { method: "GET" }, the later assignment request.method = "POST" remains possible, so method is normally inferred as string.
Likewise, the ordinary array literal const methods = ["GET", "POST"] normally becomes string[], not a fixed-length tuple.
An explicit context can prevent premature widening.
If a variable is declared as const method: "GET" | "POST" = "GET", or an object is passed to a parameter that declares that union, its initializer is checked against the expected type.
The context supplies the allowed set, and the expression must belong to it.
This comparison summarizes the usual static result of each declaration.
| Form | Main result | Restricts runtime mutation? |
|---|---|---|
const value = "on" | The binding usually retains "on" | Only prevents rebinding the variable |
let value = "on" | Usually widens to string | No |
const item = { mode: "on" } | mode is usually string | No |
const item = { mode: "on" } as const | mode is readonly "on" | Does not call Object.freeze |
as const preserves literal information
A const assertion , written as const, asks the checker not to widen a literal expression.
Object-literal properties become readonly, and array literals become readonly tuples whose elements retain their own literal types.
It fits option tables, route tables, action definitions, and other constant data maintained in source.
as const is a static instruction, not a runtime freeze.
The type syntax doesn’t appear in the emitted JavaScript, and existing mutable objects referenced by the literal aren’t recursively copied or frozen.
If runtime callers might mutate an object, design its ownership separately or apply a real freezing operation.
as const can also produce a type narrower than the API’s contract.
If an object really needs to move among several allowed values, give it a mutable union annotation rather than applying a const assertion and forcing casts later.
More precision isn’t always better; the precision has to match the value’s lifetime.
satisfies checks without replacing the expression type
The satisfies operator checks that its left-hand expression is assignable to the right-hand type while retaining useful details from the expression’s inferred type.
It works well for checking that a configuration covers required keys and that property values belong to a union, while keeping each written value specific.
Unlike a direct annotation, it generally doesn’t replace the variable’s whole visible type with the target type.
satisfies doesn’t make an object readonly and doesn’t validate runtime data.
Combine it with as const where a readonly literal is wanted; run value checks where JSON needs validation.
They solve different problems and aren’t interchangeable merely because both syntaxes follow an expression.
Comparisons trigger narrowing
When a union value is compared with a literal, control-flow narrowing removes impossible members in the matching branch.
For Speed, the condition speed === "express" makes the value exactly "express" in the true branch and leaves only "standard" in the false branch.
The result combines an ordinary JavaScript comparison with static union information.
A discriminated union extends this rule to a whole object.
Every member shares a property name but assigns it a different literal value; after that property is checked, TypeScript narrows the other fields too.
The tag must actually distinguish the members, rather than being the same broad string on every member.
A switch is a natural way to handle a closed union, but a new member is guaranteed to expose omissions only when you write an exhaustiveness check.
After every known branch returns, the remaining value should be never; passing it to a helper that accepts only never lets the compiler verify that fact.
A catch-all default that accepts anything hides new members instead.
Examples
These four examples start with one union, establish a single source for values and types, use literals as object tags, and finally compare satisfies with a direct annotation.
Each program runs on its own without network access or supporting files.
Restrict a function input
ShippingSpeed restricts shipping speed to two allowed values.
The comparison inside the function also narrows each branch to its matching literal.
type ShippingSpeed = "standard" | "express";
function dispatch(orderId: string, speed: ShippingSpeed): string {
const transit = speed === "express" ? "1 day" : "4 days";
return `${orderId}: ${speed} (${transit})`;
}
const selected: ShippingSpeed = "express";
console.log(dispatch("order-104", selected));
console.log(dispatch("order-105", "standard"));order-104: express (1 day)
order-105: standard (4 days)The annotation makes the function boundary a closed contract, while callers still pass ordinary string literals directly.
Passing "overnight" reports an error at the call site instead of waiting for the function to take an unexpected branch.
The runtime output contains only ordinary strings.
ShippingSpeed is erased during compilation, so the precise type introduces no extra runtime object or conversion.
Derive a union from a runtime tuple
When a program needs both runtime enumeration and a reusable static type, write the value list first.
typeof CHANNELS[number] reads the element types at every numeric index of the readonly tuple, producing "email" | "sms" | "push".
const CHANNELS = ["email", "sms", "push"] as const;
type Channel = (typeof CHANNELS)[number];
function isChannel(value: string): value is Channel {
return CHANNELS.some((channel) => channel === value);
}
function subscribe(input: string): string {
if (!isChannel(input)) {
return `unsupported: ${input}`;
}
return `subscribed: ${input}`;
}
console.log(CHANNELS.join(", "));
console.log(subscribe("push"));
console.log(subscribe("fax"));email, sms, push
subscribed: push
unsupported: faxThe tuple is a runtime fact, and Channel is the static fact derived from it.
Adding a channel in CHANNELS updates iteration, the guard, and the union type from one edit.
isChannel performs real comparisons, so it can narrow an external string to Channel.
The type predicate value is Channel describes the fact proved by a successful return; the checker won’t detect a lie if the implementation disagrees with that predicate.
This pattern suits a short list owned by the current program. If the server deployment, a plugin, or a database decides which values are allowed, a build-time tuple can’t pretend to be the complete runtime source.
Associate state with data through literal tags
Every UploadState member has status, but its other fields depend on that tag.
After the switch checks the tag, each branch can access only the payload that state really owns.
type UploadState =
| { status: "queued"; file: string }
| { status: "uploading"; file: string; percent: number }
| { status: "complete"; file: string; url: string }
| { status: "failed"; file: string; message: string };
function assertNever(value: never): never {
throw new Error(`Unexpected state: ${JSON.stringify(value)}`);
}
function describe(state: UploadState): string {
switch (state.status) {
case "queued":
return `${state.file}: waiting`;
case "uploading":
return `${state.file}: ${state.percent}%`;
case "complete":
return `${state.file}: ${state.url}`;
case "failed":
return `${state.file}: ${state.message}`;
default:
return assertNever(state);
}
}
const states: UploadState[] = [
{ status: "queued", file: "avatar.png" },
{ status: "uploading", file: "report.pdf", percent: 60 },
{ status: "complete", file: "map.svg", url: "/files/map.svg" },
];
for (const state of states) console.log(describe(state));avatar.png: waiting
report.pdf: 60%
map.svg: /files/map.svgThe main purpose of assertNever is visible during type checking.
If you add a "paused" member to UploadState without a matching branch, state is no longer never in default, and compilation fails.
The throw in the helper still has runtime value.
Unchecked JavaScript or a bad assertion can bypass the static type, and an unknown runtime tag won’t silently produce plausible text.
Don’t replace this union with one interface containing a broad status: string and several optional fields.
That shape allows invalid combinations such as status: "complete" without url, and control flow can’t establish relationships among the fields.
Check a configuration shape with satisfies
The configuration must contain exactly two environments, and each retry count must belong to the declared union.
satisfies performs those checks while keeping the two written retry values as 0 and 3, respectively.
type Environment = "development" | "production";
type ServiceConfig = {
endpoint: string;
retry: 0 | 1 | 2 | 3;
};
const services = {
development: { endpoint: "http://localhost:3000", retry: 0 },
production: { endpoint: "https://api.example.com", retry: 3 },
} satisfies Record<Environment, ServiceConfig>;
function retryLabel(count: 0 | 3): string {
return count === 0 ? "no retries" : "three retries";
}
console.log(`${services.development.endpoint}: ${retryLabel(services.development.retry)}`);
console.log(`${services.production.endpoint}: ${retryLabel(services.production.retry)}`);http://localhost:3000: no retries
https://api.example.com: three retriesA misspelled key or retry: 5 reports an error on the configuration object.
If the variable were directly annotated as Record<Environment, ServiceConfig>, reading a specific property would expose the full retry union 0 | 1 | 2 | 3.
This example doesn’t use as const, so mutable properties such as endpoint haven’t all become readonly.
The requirement is shape checking plus literal precision, not an assumption that the whole configuration can never change.
The right side of satisfies also disappears from emitted JavaScript.
If this configuration comes from a file rather than a source object, its contents still need parsing and validation.
Pitfalls
Fix: Give the property a union annotation if the object will change; use as const if it is constant data.
Use satisfies when you only need to check the shape without replacing the inferred type.
Fix: Perform a membership check at the trust boundary, and decide separately whether the object’s real ownership requires freezing. Type precision, runtime validity, and mutability are three separate guarantees.
Fix: For values controlled in source, correct the inference or annotation at the declaration; for external values, validate before returning the union. Don’t make an assertion the general conversion from a broad type to a narrow one.
Fix: Decide first whether the API is a closed set or an open string. When arbitrary extension values are genuinely allowed, design runtime validation, editor completion, and compatibility policy for an open protocol; don’t claim the union rejects unknown values.
Fix: End control flow in every known branch, then pass the remaining value to assertNever.
Keep the runtime throw as well, so unchecked call paths are exposed.
Fix: Use number for open numeric input and check finiteness, integrality, and range at runtime.
Write 0 | 1 | 2 | 3 only when those numbers are themselves domain choices.
Four tools for controlling precision
Annotations, as const, satisfies, and ordinary assertions can all appear next to a value, but they answer different questions.
First decide whether the site declares a contract, preserves expression precision, checks a shape, or supplies evidence the compiler can’t derive.
| Tool | What it asks the checker to do | Typical risk |
|---|---|---|
| Type annotation | Give a variable or boundary the declared target type | May intentionally widen the initializer |
as const | Preserve literals and view the literal structure as readonly | Mistaken for a runtime freeze |
satisfies | Check assignability to a target while retaining useful inference | Mistaken for runtime validation |
as SomeType | Accept type evidence supplied by the author | The evidence may be wrong |
Public function parameters and returns generally suit annotations because callers need a stable contract.
An options table in source suits as const because the value is the runtime source and shouldn’t be changed casually.
A large configuration object suits satisfies because you often want to check its keys and values while retaining specific property information.
Keep ordinary assertions for small seams already proved by some other mechanism but beyond what the checker can express.
If the same assertion repeats, go back to the data source, function signature, or validator and repair the type flow.
Many occurrences of as Status usually mean the program hasn’t recorded how a value becomes a Status, not that literal types are too strict.
These tools can be combined, and their order expresses a specific intent.
For example, a static lookup table can preserve readonly literals with as const and use satisfies to check that it covers a key set.
The combination still performs no runtime validation because both pieces of syntax are erased.
const type parameters and call-site inference
TypeScript also allows const before a generic type parameter, which gives object, array, and primitive literals written directly at a call site a more as const-like inference preference.
This is useful for tuple factories, route definers, and event-table helpers because callers don’t have to repeat as const after every argument.
It changes inference preference, not the function parameter’s runtime value.
A const type parameter affects candidates in the call expression only.
If a value is first stored in an already widened variable and then passed to the generic function, its lost literal information isn’t recovered.
The API author still needs a constraint compatible with the intended readonly result.
Don’t mark every generic parameter const merely to preserve every local literal.
When callers need mutable collections or a broad return contract, overly narrow inference adds assignment friction.
It fits APIs where “the structure written at the call site is part of the contract.”
Static sets and the runtime world
A literal union is closed only along code paths checked by TypeScript.
After type erasure, a JavaScript function can still receive any string, while any, bad assertions, and unvalidated data can all bypass the set.
The system therefore needs an explicit step that promotes a runtime value to a validated union member.
A short, stable set can use one readonly tuple to drive validation and typing, as the CHANNELS example does.
Larger protocols often have a schema, interface description, or server contract; generate the runtime parser and TypeScript type from that authority, then test that they stay synchronized.
Two handwritten lists that look the same will drift when a member is added or removed.
Membership validation is only the first step.
External input for a discriminated union must also be a non-null object, have a string discriminant, and contain every payload field required by that member.
Checking only status === "complete" and asserting the whole object still admits data with no url.
Error handling is part of the protocol design too.
An internally impossible state can use assertNever to expose a programming error, while an unknown external value may need a structured parse error, a compatibility event, or a forward-compatible path.
Don’t use one silent default for both cases.
The most reliable use of literal types connects the static contract to runtime evidence through one reviewable path. Source constants supply options, a validator proves that input belongs to them, the union preserves that proof, and control flow selects the legal payload by literal. If an assertion skips any step, the precise types downstream look more trustworthy than the real data is.
Further reading
5 questions · 1 predict-the-output · 1 spot-the-bug