DEV Community

Cover image for as const Is the Best Single Feature TypeScript Has Shipped in 5 Years
Gabriel Anhaia
Gabriel Anhaia

Posted on

as const Is the Best Single Feature TypeScript Has Shipped in 5 Years


You add a new status to your app. The string "archived" goes
into a constant array at the top of the file, and a switch
statement in the reducer handles the new case. CI is green. You
ship.

Two days later a teammate adds "deleted" to the array and
forgets the reducer. The compiler does not flag it. The status
list is typed as string[]. The reducer parameter is typed as
string. Every status in the world satisfies that signature.
The bug ships behind a feature flag. Nobody catches it for a
week, until a customer ticket arrives asking why deleted items
still appear in the active list.

That bug is one keyword away from impossible. The keyword is
as const. It landed in TypeScript 3.4 in early 2019, and
I'll argue it has quietly out-earned every feature the
TypeScript team has shipped since. Most codebases use it for
one of its four jobs and miss the other three.

What as const actually does

as const is a type assertion that asks the compiler for the
narrowest possible type of the expression in front of it.
Strings stay as their literal value, not string. Numbers stay
as their literal value, not number. Arrays become readonly
tuples, not arrays. Object properties become readonly with
their literal types, not widened.

Here is the entire mental model in one snippet.

const a = ["click", "key"];
// a: string[]

const b = ["click", "key"] as const;
// b: readonly ["click", "key"]

const c = { kind: "click", x: 12 };
// c: { kind: string; x: number }

const d = { kind: "click", x: 12 } as const;
// d: { readonly kind: "click"; readonly x: 12 }
Enter fullscreen mode Exit fullscreen mode

That is it. Everything below is a pattern that falls out of the
same idea once you start composing it with the rest of the type
system.

Pattern 1: literal-type tuples instead of widened arrays

The most visible win is in fixed-shape tuples. A coordinate
pair, a key-value pair, a [ok, value] result. Without
as const, the array is widened to a homogeneous array type
and the positional information is lost.

function origin() {
  return [0, 0];
}
// returns: number[]

function originConst() {
  return [0, 0] as const;
}
// returns: readonly [0, 0]
Enter fullscreen mode Exit fullscreen mode

The first version lets a caller pass the result anywhere a
number[] is expected. The second version is a tuple of length
two with two literal zeroes, which is what the function
actually returns. Destructuring is sharper too: with the const
version, the compiler knows the first element is 0 and the
second element is 0, not just two unrelated numbers.

The pattern matters most for return values that callers will
destructure or splat. The useState shape from React is a
tuple, and every custom hook that wraps it has to remember to
return [value, setter] as const. Forget the assertion and
callers see (StateType | SetterType)[]. Type narrowing dies
at the boundary.

Pattern 2: const-enum replacement that stays serialisable

TypeScript enums have aged badly. They emit runtime objects
with reverse mappings nobody uses, they do not tree-shake well
in some configurations, const enum is broken under
isolated-modules and is now flagged in the official
handbook

as something to avoid in libraries. The Bun team has publicly
discouraged TypeScript enums in favour of as const objects.
Most modern codebases have moved on.

The replacement is a frozen object plus as const.

const Status = {
  Pending: "pending",
  Active: "active",
  Archived: "archived",
  Deleted: "deleted",
} as const;

type Status = (typeof Status)[keyof typeof Status];
// "pending" | "active" | "archived" | "deleted"

function setStatus(s: Status): void {
  // ...
}

setStatus(Status.Active);     // ok
setStatus("active");          // ok — Status is the union too
setStatus("typo");            // error
Enter fullscreen mode Exit fullscreen mode

The runtime value is a plain object. The keys are usable as
named constants (Status.Active). The type Status is the
union of all the values. JSON serialisation is automatic — the
values are real strings. Tree-shaking is whatever your bundler
does with object property access, which is fine in every modern
toolchain.

The Status type expression deserves a closer look because it
shows up in the next two patterns as well.

typeof Status                // the type of the object itself
keyof typeof Status          // "Pending" | "Active" | ...
(typeof Status)[keyof typeof Status]  // values: "pending" | "active" | ...
Enter fullscreen mode Exit fullscreen mode

The chain reads as: take the type of the object, take its keys,
index the object by those keys, and you get the union of value
types. The whole expression is a derivation. There is no
hand-written type Status = "pending" | "active" | ... line
that has to be kept in sync with the object. Add a key, the
type updates.

Pattern 3: deep-readonly via Readonly<typeof x>

Once a value is frozen with as const, the type already has
readonly markers on every property. That makes the structural
shape immutable to the type checker for free.

const config = {
  retries: 3,
  timeout: 5_000,
  endpoints: {
    auth: "/v1/auth",
    users: "/v1/users",
  },
} as const;

config.retries = 5;
// error: Cannot assign to 'retries' because it is a
// read-only property.

config.endpoints.auth = "/v2/auth";
// error: same.
Enter fullscreen mode Exit fullscreen mode

The as const recurses into the object literal. Every
property, at every depth, picks up readonly and a literal
type. You do not need a separate Readonly<T> wrapper for
shallow cases. For cases where you have a non-const value and
want a readonly view of its type, Readonly<typeof x> is the
shorthand, but the more interesting use is to derive the
literal-typed value type from the const expression and then
hand it around as a parameter type.

type Config = typeof config;
// readonly { retries: 3; timeout: 5000;
//   endpoints: readonly { auth: "/v1/auth";
//                         users: "/v1/users" } }

function withConfig(c: Config): void {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Callers cannot synthesise a fake Config with the wrong
endpoint string. The type carries the values, not just the
shape.

Pattern 4: deriving a union from a constant

This is the one that is worth showing side by side, because it
is the pattern that replaces the most hand-written code in the
average codebase.

The hand-written version. Keep two things in sync, or the
runtime and the type lie to each other.

const STATUSES = ["pending", "active", "archived", "deleted"];
type Status = "pending" | "active" | "archived" | "deleted";

function isStatus(s: string): s is Status {
  return STATUSES.includes(s);
}
Enter fullscreen mode Exit fullscreen mode

The as const-derived version. Same name, second take — one
source of truth, the type falls out.

const STATUSES = [
  "pending",
  "active",
  "archived",
  "deleted",
] as const;

type Status = (typeof STATUSES)[number];
// "pending" | "active" | "archived" | "deleted"

function isStatus(s: string): s is Status {
  return (STATUSES as readonly string[]).includes(s);
}
Enter fullscreen mode Exit fullscreen mode

The cast on STATUSES inside isStatus is there for a real
reason. After as const, STATUSES has type
readonly ["pending", "active", "archived", "deleted"] and
Array#includes types its argument as the element union. Pass
in an arbitrary string and TypeScript raises TS2345 because
string is wider than that union. Widening the array back to
readonly string[] for the call lets includes accept any
string, which is exactly what a guard wants. The function still
returns s is Status, so the type narrowing on the caller side
is unaffected.

The runtime array is the source of truth. The type is a
projection. Add "banned" to the array, the type updates,
every switch statement that exhausts Status flags as
non-exhaustive. The compiler does the bookkeeping a careful
human used to do.

(typeof STATUSES)[number] is the readable form of "index this
tuple type by any numeric position." Since every position holds
a literal string, the resulting type is the union of those
strings. The same idiom works for any tuple of HTTP methods,
supported locales, or feature flags.

The reducer for our opening bug becomes exhaustive by force.

function reduce(s: Status): string {
  switch (s) {
    case "pending":  return "Pending review";
    case "active":   return "In use";
    case "archived": return "Hidden";
    case "deleted":  return "Gone";
  }
}
Enter fullscreen mode Exit fullscreen mode

If you delete a case the function fails to compile, because the
switch returns string | undefined and the signature
promises string (under strict or noImplicitReturns; on a
permissive tsconfig the missing-case error goes silent). Add
"banned" to the array, the same function fails again until
you handle it. The bug from the lead paragraph cannot ship.

The one sharp edge

as const does not deep-recurse into nested arrays the way
people expect when the array is built dynamically. The literal
inference works on the syntactic shape at the point the
assertion is applied. Build the array elsewhere and pass it
through, and the inner types widen.

const inner = [1, 2, 3];
const outer = [inner, inner] as const;
// readonly [number[], number[]]
// not: readonly [readonly [1, 2, 3], readonly [1, 2, 3]]
Enter fullscreen mode Exit fullscreen mode

The fix is to apply as const at the deepest point, not the
outermost.

const inner = [1, 2, 3] as const;
const outer = [inner, inner] as const;
// readonly [readonly [1, 2, 3], readonly [1, 2, 3]]
Enter fullscreen mode Exit fullscreen mode

For dynamically built shapes, you need a helper. The standard
trick is a generic that re-asserts on each level.

type DeepReadonly<T> = T extends (infer U)[]
  ? readonly DeepReadonly<U>[]
  : T extends object
    ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
    : T;
Enter fullscreen mode Exit fullscreen mode

Most codebases never need it. The flat case covers eighty
percent of what as const is for. When you do need it, the
mapped type above is the canonical form.

Why this one keyword wins

Look at what it replaces. Hand-maintained string union types
that drift from their runtime constants. Enums that emit
unwanted runtime baggage. Tuples that widen into arrays the
moment they leave a function. Configuration objects that the
compiler will let you mutate at runtime because nobody wrote
Readonly everywhere. The hand-rolled isStatus guard that is
out of date the day you add a status.

as const is two tokens. It costs nothing at runtime. It
removes a category of bug that grows with every new constant
you add to the codebase. There is no other TypeScript feature
in the last five years that pays back this much from this
small a footprint.

The next time you write a STATUSES or ROLES array, or an
enum-like object, end the literal with as const. Add
type X = (typeof X)[number] for arrays or
type X = (typeof X)[keyof typeof X] for objects (the type X
and the const X share the name; TypeScript keeps them in
separate namespaces). Delete the hand-written union you would
have written underneath. The codebase gets two lines shorter
and the bug surface gets a lot smaller.


If this was useful

as const is one of the patterns TypeScript Essentials
unpacks alongside the day-to-day machinery — narrowing, modules,
async, the type-system features that show up in every working
codebase. If the four patterns above clicked and you want the
same depth of treatment for the rest of TypeScript's daily
surface area, that is the book. The TypeScript Type System
picks up where this post ends, with the conditional types,
mapped types, and infer patterns that compose with as const
into library-grade types.

If you are coming from JVM languages, Kotlin and Java to
TypeScript
makes the bridge into structural typing. If you are
coming from PHP 8+, PHP to TypeScript covers the same ground
from the other side. TypeScript in Production covers the
build, monorepo, and dual-publish concerns the type system
itself does not touch.

The five-book set:

  • TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: amazon.com/dp/B0GZB7QRW3
  • The TypeScript Type System — From Generics to DSL-Level Types — deep dive: amazon.com/dp/B0GZB86QYW
  • Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: amazon.com/dp/B0GZB2333H
  • PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP devs: amazon.com/dp/B0GZBD5HMF
  • TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: amazon.com/dp/B0GZB7F471

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)