Literal Types and Const Assertions in TypeScript


What Are Literal Types
TypeScript infers broad types by default. Write let direction = "left" and TypeScript types it as string — because you might reassign it to "right" or "north" later. But sometimes a value isn't just any string; it's specifically "left". Literal types let you express exactly that.
A literal type is a type that represents a single, exact value rather than a broad category. Instead of string, you get "left". Instead of number, you get 42. Instead of boolean, you get true. Combine literal types with union types and you get one of TypeScript's most practical features: type-safe enumerations built from plain values.
Literal Types Are Subtypes
Every literal type is a subtype of its primitive. The type "left" is a
subtype of string, 42 is a subtype of number, and true is a subtype of
boolean. This means a literal type can be used anywhere its parent primitive
is expected, but not the other way around.
String Literal Types
A string literal type constrains a value to one exact string:
let status: "active" = "active";
status = "active"; // OK
status = "inactive"; // Error: type '"inactive"' is not assignable to type '"active"'On its own, a single literal type isn't useful — the variable can only ever hold that one value. The real power comes when you combine literals with union types (covered in the next section).
TypeScript widens literal types when you use let declarations without a type annotation:
let a = "left"; // inferred as string (widened)
const b = "left"; // inferred as "left" (literal — const can't be reassigned)const prevents reassignment, so TypeScript keeps the literal type. let could be reassigned to any string, so TypeScript widens it to string. Understanding this widening behaviour is key to working effectively with literal types.
Number and Boolean Literals
Number and boolean values work the same way:
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;
function roll(): DiceRoll {
return (Math.floor(Math.random() * 6) + 1) as DiceRoll;
}
type Toggle = true | false; // equivalent to boolean, but explicit about intentNumber literals are particularly useful for fixed sets of allowed values — HTTP status codes, port numbers, bit flags, or configuration constants:
type HttpSuccessCode = 200 | 201 | 204;
type HttpErrorCode = 400 | 401 | 403 | 404 | 500;
type HttpStatusCode = HttpSuccessCode | HttpErrorCode;
function handleResponse(code: HttpStatusCode): void {
if (code === 200 || code === 201) {
console.log("Success");
} else if (code === 400) {
console.log("Bad request");
}
// TypeScript ensures you only pass valid status codes
}Literal Types in Unions
Combining literal types with | creates the most common real-world pattern: a type that can be one of a fixed set of values, similar to an enum but using plain strings or numbers:
type Direction = "north" | "south" | "east" | "west";
type Alignment = "left" | "center" | "right";
type Status = "pending" | "active" | "cancelled" | "completed";
function move(direction: Direction, steps: number): void {
console.log(`Moving ${steps} steps ${direction}`);
}
move("north", 3); // OK
move("up", 3); // Error: argument of type '"up"' is not assignable to parameter of type 'Direction'TypeScript narrows the type inside conditionals, giving you autocomplete and exhaustiveness checking:
function getStatusLabel(status: Status): string {
if (status === "pending") return "Awaiting approval";
if (status === "active") return "In progress";
if (status === "cancelled") return "Cancelled";
if (status === "completed") return "Done";
// TypeScript knows this is unreachable if all cases are covered
const exhausted: never = status;
return exhausted;
}Literal Unions vs Enums
Literal union types and TypeScript enums serve similar purposes, but literal unions have advantages: they compile to plain strings (no runtime overhead), they're easier to serialize to JSON, and they work naturally with switch statements and discriminated unions. Many TypeScript style guides prefer type Status = "active" | "inactive" over enum Status { Active, Inactive }.
The as const Assertion
When you have a plain JavaScript object or array, TypeScript widens the types of its properties. as const tells TypeScript to infer the narrowest possible types and mark everything as readonly:
// Without as const — TypeScript widens the types
const config = {
host: "localhost", // inferred as string
port: 3000, // inferred as number
debug: true, // inferred as boolean
};
// With as const — TypeScript keeps the exact literal types
const config2 = {
host: "localhost", // inferred as "localhost"
port: 3000, // inferred as 3000
debug: true, // inferred as true
} as const;
config2.host = "other"; // Error: cannot assign to 'host' because it is read-onlyConst Arrays and Tuples
Without as const, an array literal is typed as T[]. With as const, it becomes a readonly tuple of literal types:
// Without as const
const directions = ["north", "south", "east", "west"];
// type: string[]
// With as const
const directions2 = ["north", "south", "east", "west"] as const;
// type: readonly ["north", "south", "east", "west"]
// Derive a union type from the const array
type Direction = (typeof directions2)[number];
// type: "north" | "south" | "east" | "west"The pattern typeof array[number] extracts a union of all element types — a powerful way to keep your source of truth as a plain array while still getting full type safety from it.
Const Objects
as const on objects makes all properties readonly and preserves their literal types at every nesting level:
const ROLES = {
admin: "admin",
editor: "editor",
viewer: "viewer",
} as const;
type Role = (typeof ROLES)[keyof typeof ROLES];
// type: "admin" | "editor" | "viewer"
function hasRole(role: Role): boolean {
return role !== "viewer";
}
hasRole(ROLES.admin); // OK
hasRole("admin"); // OK — "admin" is assignable to Role
hasRole("superuser"); // Error: not assignable to type 'Role'Real-World Patterns
Literal types shine when defining configuration, routing, and state machines. Here's a practical event system built entirely from literal unions:
type EventName =
| "user:created"
| "user:updated"
| "user:deleted"
| "order:placed"
| "order:fulfilled";
type UserCreatedPayload = { id: string; email: string };
type UserUpdatedPayload = {
id: string;
changes: Partial<{ email: string; name: string }>;
};
type UserDeletedPayload = { id: string };
type OrderPlacedPayload = { orderId: string; userId: string; total: number };
type OrderFulfilledPayload = { orderId: string; fulfilledAt: Date };
type EventPayloadMap = {
"user:created": UserCreatedPayload;
"user:updated": UserUpdatedPayload;
"user:deleted": UserDeletedPayload;
"order:placed": OrderPlacedPayload;
"order:fulfilled": OrderFulfilledPayload;
};
function emit<E extends EventName>(
event: E,
payload: EventPayloadMap[E]
): void {
console.log(`Event: ${event}`, payload);
}
// TypeScript enforces the correct payload shape for each event
emit("user:created", { id: "usr-001", email: "[email protected]" });
emit("order:placed", { orderId: "ord-001", userId: "usr-001", total: 99.99 });
emit("user:created", { orderId: "wrong" }); // Error: wrong payload shapeThe combination of a literal union for event names and a mapped object type for payloads gives you a fully type-safe event system — TypeScript connects the event name to its required payload automatically.
Best Practices
Use literal types when:
- A value should be one of a fixed, known set of strings or numbers
- You want enum-like behaviour without the runtime overhead
- Building discriminated unions (a
kindortypefield)
Use as const when:
- You have a constant object or array that serves as a source of truth
- You need to derive a union type from an array of values
- You want TypeScript to infer the narrowest possible types from a literal
// Good — derive a union from a const array (single source of truth)
const PERMISSIONS = ["read", "write", "delete", "admin"] as const;
type Permission = (typeof PERMISSIONS)[number];
// Avoid — defining the array and the union separately (they can drift apart)
const PERMISSIONS2 = ["read", "write", "delete", "admin"];
type Permission2 = "read" | "write" | "delete" | "admin"; // must keep in sync manuallyas const Does Not Make Runtime Values Immutable
as const is a compile-time annotation — it tells TypeScript to treat the
value as readonly and use literal types. It does not call
Object.freeze() or otherwise prevent mutation at runtime. If you need true
runtime immutability, use Object.freeze() in addition to as const.
Key Takeaways
- Literal types constrain values to a single exact string, number, or boolean —
"left",42,true constdeclarations preserve literal types;letdeclarations widen them to their primitive parent- Combining literal types with
|creates type-safe enumerations — often preferable to TypeScript enums as constlocks down object and array literals: all properties becomereadonlyand types stay narrow- The
typeof arr[number]pattern derives a union type from a const array, keeping definitions in sync
The Building Blocks Are in Place
You now have three of the most important tools in TypeScript's advanced type system: union types for OR logic, intersection types for AND logic, and literal types for exact values. These three primitives underpin type guards, discriminated unions, and nearly every advanced pattern you'll encounter. Next up: type guards — the runtime mechanisms that make narrowing precise and safe.