Notifications

No notifications

/Phase 2

Literal Types & as const

A literal type isn't string or number — it's the *exact value*: "loading", 42, true. Combined with unions and as const, literal types power autocomplete, exhaustive switches, and TypeScript's most magical feature: template literal types.

On this page

Detailed Theory

What is a literal type?

Most types describe a set of values. A literal type describes one value.

let s: "hello" = "hello";
// s = "world"; // ❌
const n: 42 = 42;
const b: true = true;

On their own they're not very useful — but combined into unions, they become enums-without-runtime-cost.

Literal-union types

type Direction = "up" 
"down""left"
"right"; type HttpStatus = 200
404
500; type Truthy = true;

Autocomplete works in your editor for free.

Inference & widening

By default TS *widens* literal types from variables (so you can reassign):

let s = "hello";  // string  (widened)
const c = "hello"; // "hello" (preserved — const can't change)

That's why const is the default for "give me the literal".

Widening in objects

Object properties are also widened — even on const:

const config = { mode: "lazy" };
// config: { mode: string }  — not { mode: "lazy" }

as const — freeze it

Add as const to a literal expression and TS treats every property as readonly + literal:

const config = { mode: "lazy", retries: 3 } as const;
// { readonly mode: "lazy"; readonly retries: 3 }

Same trick on arrays:

const methods = ["GET", "POST", "PUT"] as const;
// readonly ["GET", "POST", "PUT"]
type Method = typeof methods[number];
// "GET" 
"POST""PUT"

Annotation widening — as const vs explicit literal

Without as const, you can still narrow by annotating:

const dir: "up"
"down" = "up";

Or by passing into a function whose parameter is a literal type — TS infers the literal at the call site (called *contextual typing*):

function move(d: "up"
"down") { /* … */ } move("up"); // ✅ no widening

Template literal types — strings as types

You can build new string-literal types from existing ones using template-string syntax:

type Greeting = Hello, ${string};
const g1: Greeting = "Hello, Ada";   // ✅
const g2: Greeting = "Hi, Ada";       // ❌

type Lang = "en" | "es"; type Bundle = "common" | "errors"; type I18nKey = ${Lang}.${Bundle}; // "en.common"

"en.errors""es.common"
"es.errors"

Combined with intrinsic helpers (Uppercase, Lowercase, Capitalize, Uncapitalize):

type EventName<T extends string> = on${Capitalize<T>};
type ClickHandler = EventName<"click">;  // "onClick"

Discriminating with literal tags (recap)

Literal types are the building block for discriminated unions:

type Action =
  | { type: "INC"; by: number }
  | { type: "RESET" };

function reduce(a: Action) { switch (a.type) { case "INC": return a.by; case "RESET": return 0; } }

Numeric literals & enums replacement

type DiceRoll = 1 
2345
6; function nextRoll(): DiceRoll { return (Math.floor(Math.random() * 6) + 1) as DiceRoll; }

Real-world patterns

1. Function with autocomplete options

function fetchJSON(url: string, method: "GET" | "POST" = "GET") {}
fetchJSON("/x", "GET"); // suggests GET / POST

2. Object key whitelist

const ROLES = ["admin", "editor", "viewer"] as const;
type Role = typeof ROLES[number];

function hasAccess(r: Role) { /* … */ }

3. Type-safe routes

type Route = "/" 
"/about"
"/users/:id"; function go(r: Route) { /* … */ } go("/about"); // ✅

4. Branded primitives (newtype pattern)

Want a UserId that's a string but not interchangeable with any other string?

type UserId = string & { readonly __brand: unique symbol };
function makeUserId(s: string): UserId { return s as UserId; }

function loadUser(id: UserId) {} loadUser(makeUserId("u1")); // ✅ // loadUser("u1"); // ❌

When NOT to use literal types

  • For free-form user input (use string).
  • For values that change frequently — maintenance pain.
  • When the union has dozens of members; a real enum or DB-driven set is cleaner.