Skip to content
JL

Home

About

Blog

Contact

Shop

Portfolio

Privacy

TOS

Click to navigate

  1. Home
  2. Joshua R. Lehman's Blog
  3. Literal Types and Const Assertions in TypeScript

Table of contents

  • Share on X
  • Discuss on X

Related Articles

Union Types: Multiple Possibilities in TypeScript
TypeScript
7m
Apr 5, 2026

Union Types: Multiple Possibilities in TypeScript

A practical guide to TypeScript union types — combining multiple types with OR logic, accessing shared properties, and narrowing unions safely at runtime.

#Union Types#Type Narrowing+5
Intersection Types: Combining Types in TypeScript
TypeScript
8m
Apr 12, 2026

Intersection Types: Combining Types in TypeScript

A practical guide to TypeScript intersection types — combining multiple types into one with AND logic, merging object shapes, and building flexible mixin patterns.

#Intersection Types#TypeScript Types+5
Mastering TypeScript Utility Types
TypeScript
9m
Feb 1, 2026

Mastering TypeScript Utility Types

TypeScript provides powerful built-in utility types that help you transform existing types without rewriting them. This guide covers the most essential utility types and how to use them effectively in your projects.

#TypeScript#Utility Types+4
Ask me anything! 💬

© Joshua R. Lehman

Full Stack Developer

Crafted with passion • Built with modern web technologies

2026 • All rights reserved

Contents

  • What Are Literal Types
  • String Literal Types
  • Number and Boolean Literals
  • Literal Types in Unions
  • The as const Assertion
    • Const Arrays and Tuples
    • Const Objects
  • Real-World Patterns
  • Best Practices
  • Key Takeaways
TypeScript

Literal Types and Const Assertions in TypeScript

April 19, 2026•7 min read
Joshua R. Lehman
Joshua R. Lehman
Author
TypeScript literal types and const assertions
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 intent

Number 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-only

Const 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 shape

The 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 kind or type field)

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 manually

as 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
  • const declarations preserve literal types; let declarations widen them to their primitive parent
  • Combining literal types with | creates type-safe enumerations — often preferable to TypeScript enums
  • as const locks down object and array literals: all properties become readonly and 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.