Skip to content
Codeloom
TypeScript

TypeScript const Assertions and Readonly Inference

Deep dive into as const for literal types, readonly arrays, and deeply immutable objects in TypeScript.

·6 min read · By Codeloom
Intermediate 8 min read

What you'll learn

  • How as const narrows types to their literal values
  • Readonly arrays and tuples from const assertions
  • Deriving union types from constant objects

Prerequisites

  • Basic TypeScript types
  • Familiarity with literal types and unions

The as const assertion, introduced in TypeScript 3.4, tells the compiler to infer the narrowest possible type for a value. Strings become string literals, numbers become numeric literals, arrays become readonly tuples, and objects become deeply readonly with literal property types. This is the foundation for building type-safe constants without duplicating type definitions.

What as const Does

Without as const, TypeScript widens literals to their base types.

// Without as const
const config = {
  env: 'production',
  port: 3000,
  features: ['auth', 'logging'],
};
// typeof config = {
//   env: string;
//   port: number;
//   features: string[];
// }

// With as const
const configConst = {
  env: 'production',
  port: 3000,
  features: ['auth', 'logging'],
} as const;
// typeof configConst = {
//   readonly env: 'production';
//   readonly port: 3000;
//   readonly features: readonly ['auth', 'logging'];
// }

Every level is affected. The object properties become readonly, strings narrow to their literal values, and the array becomes a readonly tuple with literal element types.

Deriving Union Types From Constants

The most powerful use of as const is deriving types from values instead of writing them twice.

const ROLES = ['admin', 'editor', 'viewer'] as const;

type Role = (typeof ROLES)[number];
// 'admin' | 'editor' | 'viewer'

function hasRole(user: { roles: Role[] }, role: Role): boolean {
  return user.roles.includes(role);
}

hasRole({ roles: ['admin'] }, 'admin');   // valid
hasRole({ roles: ['admin'] }, 'manager'); // error

You define the data once and derive the type from it. Adding a new role to the array automatically extends the union.

Object Constants and keyof

const STATUS_MAP = {
  pending: { label: 'Pending', color: 'gray' },
  active: { label: 'Active', color: 'green' },
  archived: { label: 'Archived', color: 'red' },
} as const;

type StatusKey = keyof typeof STATUS_MAP;
// 'pending' | 'active' | 'archived'

type StatusLabel = (typeof STATUS_MAP)[StatusKey]['label'];
// 'Pending' | 'Active' | 'Archived'

function getStatusLabel(status: StatusKey): string {
  return STATUS_MAP[status].label;
}

This pattern eliminates the need for enums in many cases. The object is both the runtime data and the single source of truth for types.

Const Assertions With Arrays as Tuples

Without as const, TypeScript infers arrays with flexible length and widened element types. With it, you get fixed-length tuples.

// Without as const: string[]
const colors = ['red', 'green', 'blue'];

// With as const: readonly ['red', 'green', 'blue']
const colorsConst = ['red', 'green', 'blue'] as const;

// You can access each element's literal type
type First = (typeof colorsConst)[0]; // 'red'
type Second = (typeof colorsConst)[1]; // 'green'

This is essential for functions that need to know exact tuple positions.

function createPair<T extends readonly [unknown, unknown]>(pair: T): T {
  return pair;
}

const pair = createPair(['hello', 42] as const);
// readonly ['hello', 42]
// pair[0] is 'hello', pair[1] is 42

Enum-Like Patterns Without Enums

Many teams prefer as const objects over TypeScript enums because they are plain JavaScript at runtime.

const Direction = {
  Up: 'UP',
  Down: 'DOWN',
  Left: 'LEFT',
  Right: 'RIGHT',
} as const;

type Direction = (typeof Direction)[keyof typeof Direction];
// 'UP' | 'DOWN' | 'LEFT' | 'RIGHT'

function move(direction: Direction): void {
  switch (direction) {
    case Direction.Up:
      console.log('Moving up');
      break;
    case Direction.Down:
      console.log('Moving down');
      break;
    case Direction.Left:
      console.log('Moving left');
      break;
    case Direction.Right:
      console.log('Moving right');
      break;
  }
}

move(Direction.Up);   // valid
move('UP');            // also valid, same type
move('DIAGONAL');      // error

Unlike enums, this pattern produces no extra JavaScript. The object is a regular frozen-looking constant, and the type is a union of string literals.

Function Parameters With Const Type Parameters

TypeScript 5.0 introduced const type parameters, which automatically infer literal types for generic arguments without requiring the caller to use as const.

function createConfig<const T extends Record<string, unknown>>(config: T): T {
  return config;
}

// Without const type parameter, T would widen string values
// With it, literal types are preserved automatically
const config = createConfig({
  apiUrl: 'https://api.example.com',
  retries: 3,
  verbose: true,
});
// typeof config = {
//   apiUrl: 'https://api.example.com';
//   retries: 3;
//   verbose: true;
// }

This is a game-changer for library authors. Users get literal inference without remembering to write as const.

function defineRoutes<const T extends Record<string, string>>(routes: T): T {
  return routes;
}

const routes = defineRoutes({
  home: '/',
  about: '/about',
  contact: '/contact',
});

type RouteName = keyof typeof routes;
// 'home' | 'about' | 'contact'

Combining as const With satisfies

As covered in the satisfies article, combining both gives you validation and maximum precision.

type Theme = {
  colors: Record<string, string>;
  spacing: Record<string, number>;
};

const theme = {
  colors: {
    primary: '#0066cc',
    secondary: '#cc6600',
    background: '#ffffff',
  },
  spacing: {
    sm: 4,
    md: 8,
    lg: 16,
    xl: 32,
  },
} as const satisfies Theme;

// Keys are precise unions, values are literal types
type SpacingKey = keyof typeof theme.spacing;
// 'sm' | 'md' | 'lg' | 'xl'

Readonly vs Immutable

It is important to understand that readonly is a compile-time constraint. It does not freeze objects at runtime.

const data = { value: 42 } as const;
// TypeScript prevents: data.value = 100;
// But at runtime, nothing stops: (data as any).value = 100;

If you need runtime immutability, use Object.freeze. For deep freezing, combine as const with a recursive Object.freeze wrapper.

function deepFreeze<T extends Record<string, unknown>>(obj: T): Readonly<T> {
  Object.keys(obj).forEach((key) => {
    const value = obj[key];
    if (typeof value === 'object' && value !== null) {
      deepFreeze(value as Record<string, unknown>);
    }
  });
  return Object.freeze(obj);
}

When Not to Use as const

Do not use as const on values that need to be mutable. If you declare an array and intend to push to it later, as const makes it readonly and you will not be able to mutate it.

Also avoid as const when the wider type is intentionally needed. If a function should accept any string, narrowing to a literal makes the API too restrictive.

Wrap Up

The as const assertion is one of TypeScript’s most useful tools. It narrows values to literal types, creates readonly tuples from arrays, and makes objects deeply readonly. Use it to derive union types from constant data, replace enums with plain objects, and build type-safe configurations. Pair it with satisfies for validated precision and const type parameters for ergonomic library APIs.