TypeScript const Assertions: Literal Type Inference

TypeScript's type inference is generally excellent, but it makes assumptions that don't always align with your intentions. When you declare a variable with `let` or assign a primitive value,...

Key Insights

  • TypeScript widens primitive types by default ("hello" becomes string), which loses valuable type information—const assertions preserve exact literal types with as const syntax
  • Const assertions make objects deeply readonly and convert arrays into readonly tuples, enabling type-safe configurations and eliminating entire classes of runtime errors
  • Use const assertions instead of enums for lightweight, tree-shakeable constant definitions that provide better autocomplete and don’t generate JavaScript code

Introduction to Type Widening

TypeScript’s type inference is generally excellent, but it makes assumptions that don’t always align with your intentions. When you declare a variable with let or assign a primitive value, TypeScript “widens” the type to be more permissive:

let status = "success"; // type: string
let count = 42;         // type: number
let enabled = true;     // type: boolean

This widening behavior makes sense for mutable variables—you might want to reassign status to "error" later. But it creates problems when you want TypeScript to remember the exact value. Consider this common scenario:

function handleResponse(status: "success" | "error" | "pending") {
  // implementation
}

let currentStatus = "success";
handleResponse(currentStatus); // Error: Argument of type 'string' is not assignable to parameter of type '"success" | "error" | "pending"'

The function expects a specific literal type, but TypeScript widened currentStatus to the general string type. You could use const instead of let, but that doesn’t help with objects or when you need to pass values around. This is where const assertions become invaluable.

Basic const Assertion Syntax

The as const assertion tells TypeScript: “treat this value as a literal type and make it readonly.” It’s a simple suffix that fundamentally changes how TypeScript infers types:

let status = "success" as const; // type: "success"
let port = 3000 as const;        // type: 3000
let enabled = true as const;     // type: true

Now status has the literal type "success" instead of string. This works perfectly with discriminated unions and function parameters expecting specific values:

function handleResponse(status: "success" | "error" | "pending") {
  // implementation
}

const currentStatus = "success" as const;
handleResponse(currentStatus); // Works perfectly

The assertion doesn’t change the runtime value—it’s purely a compile-time hint to the type system.

Const Assertions with Objects

Const assertions become truly powerful with objects. Without as const, object properties are inferred as their general types and remain mutable:

const config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
  retries: 3
};

// Inferred type:
// {
//   apiUrl: string;
//   timeout: number;
//   retries: number;
// }

config.apiUrl = "https://malicious-site.com"; // Allowed, but dangerous

Add as const and everything changes:

const config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
  retries: 3
} as const;

// Inferred type:
// {
//   readonly apiUrl: "https://api.example.com";
//   readonly timeout: 5000;
//   readonly retries: 3;
// }

config.apiUrl = "https://malicious-site.com"; // Error: Cannot assign to 'apiUrl' because it is a read-only property

The const assertion works recursively through nested objects:

const appConfig = {
  database: {
    host: "localhost",
    port: 5432,
    credentials: {
      username: "admin",
      password: "secret"
    }
  },
  features: {
    darkMode: true,
    analytics: false
  }
} as const;

// All properties at every level are readonly with literal types
appConfig.database.port = 3306; // Error: readonly
appConfig.features.darkMode = false; // Error: readonly

This deep immutability prevents accidental mutations and enables TypeScript to provide precise type checking throughout your codebase.

Const Assertions with Arrays

Arrays get special treatment with const assertions—they become readonly tuples with exact literal types:

const colors = ["red", "green", "blue"] as const;

// Inferred type: readonly ["red", "green", "blue"]
// NOT: string[]

This preserves both the length and the exact type of each element:

type Color = typeof colors[number]; // "red" | "green" | "blue"

function setColor(color: Color) {
  // implementation
}

colors.forEach(color => setColor(color)); // Works perfectly

Without as const, you’d get a string[] type, and you’d need to manually define the union type. Const assertions eliminate that duplication.

Readonly tuples prevent mutations:

const point = [10, 20] as const;

point.push(30);  // Error: Property 'push' does not exist on type 'readonly [10, 20]'
point[0] = 15;   // Error: Cannot assign to '0' because it is a read-only property

This is perfect for coordinate pairs, RGB values, or any fixed-length data structure where mutations would be bugs.

Practical Use Cases

Const assertions excel in scenarios where you need type-safe constants without the overhead of enums.

Enum Alternatives:

const UserRole = {
  Admin: "admin",
  Editor: "editor",
  Viewer: "viewer"
} as const;

type UserRole = typeof UserRole[keyof typeof UserRole]; // "admin" | "editor" | "viewer"

function checkPermission(role: UserRole) {
  if (role === UserRole.Admin) {
    // TypeScript knows role is "admin" here
  }
}

Redux Action Types:

const ActionTypes = {
  FETCH_USER: "FETCH_USER",
  UPDATE_USER: "UPDATE_USER",
  DELETE_USER: "DELETE_USER"
} as const;

type Action = 
  | { type: typeof ActionTypes.FETCH_USER; id: string }
  | { type: typeof ActionTypes.UPDATE_USER; id: string; data: User }
  | { type: typeof ActionTypes.DELETE_USER; id: string };

API Endpoint Configuration:

const API_ENDPOINTS = {
  users: "/api/users",
  posts: "/api/posts",
  comments: "/api/comments"
} as const;

type Endpoint = typeof API_ENDPOINTS[keyof typeof API_ENDPOINTS];

async function fetchData(endpoint: Endpoint) {
  return fetch(endpoint);
}

fetchData(API_ENDPOINTS.users); // Type-safe
fetchData("/api/invalid");      // Error

HTTP Status Codes:

const HTTP_STATUS = {
  OK: 200,
  CREATED: 201,
  BAD_REQUEST: 400,
  UNAUTHORIZED: 401,
  NOT_FOUND: 404
} as const;

function handleStatus(status: typeof HTTP_STATUS[keyof typeof HTTP_STATUS]) {
  switch (status) {
    case HTTP_STATUS.OK:
      return "Success";
    case HTTP_STATUS.NOT_FOUND:
      return "Resource not found";
    // TypeScript ensures exhaustive checking
  }
}

Const Assertions vs Enums vs Type Literals

Each approach has its place:

// 1. Enum (generates JavaScript code)
enum Direction {
  North = "NORTH",
  South = "SOUTH",
  East = "EAST",
  West = "WEST"
}

// 2. Const assertion (no runtime code)
const Direction = {
  North: "NORTH",
  South: "SOUTH",
  East: "EAST",
  West: "WEST"
} as const;

type Direction = typeof Direction[keyof typeof Direction];

// 3. Type literal (type-only, no runtime value)
type Direction = "NORTH" | "SOUTH" | "EAST" | "WEST";

Use enums when: You need reverse mappings, numeric auto-increment, or want a clear namespace in both types and runtime code.

Use const assertions when: You want lightweight, tree-shakeable constants with excellent type inference and don’t need reverse lookups.

Use type literals when: You only need compile-time type checking without runtime values, or when working with external APIs that already define the strings.

Const assertions often win for modern TypeScript because they don’t generate extra JavaScript, work naturally with tree-shaking, and provide better autocomplete in many editors.

Common Pitfalls and Best Practices

Const assertions only work with literal values, not computed ones:

const timestamp = Date.now() as const; // Still inferred as number, not a literal
const random = Math.random() as const; // Still number

// This works because it's a literal:
const config = {
  created: "2024-01-01",  // Literal string
  version: 1              // Literal number
} as const;

Be cautious with large objects—const assertions create very specific types that can slow down the compiler:

// This creates a massive type that TypeScript must track
const hugeConfig = {
  /* thousands of properties */
} as const;

For truly massive configurations, consider using regular types or splitting into smaller const-asserted chunks.

Const assertions compose well with utility types:

const routes = {
  home: "/",
  about: "/about",
  contact: "/contact"
} as const;

type Route = typeof routes[keyof typeof routes];
type RouteName = keyof typeof routes;

// Create a mutable version if needed
type MutableRoutes = {
  -readonly [K in keyof typeof routes]: typeof routes[K]
};

When working with functions that expect mutable arrays, you may need to cast:

const items = [1, 2, 3] as const;

function processItems(arr: number[]) {
  arr.push(4); // Mutates the array
}

processItems(items); // Error: readonly array not assignable to mutable array
processItems([...items]); // OK: creates a mutable copy

Const assertions are a simple feature with profound implications for type safety. They eliminate the gap between runtime constants and compile-time types, enabling TypeScript to catch bugs that would otherwise slip through. Start using them wherever you have configuration objects, constant arrays, or any value that should never change—your future self will thank you when TypeScript catches that typo in a status string at compile time instead of production.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.