Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 043 / 100

readonly and as const: a practical guide for developers

Make immutability visible in types.

Expona Admin9 min read

readonly and as const: Making Immutability Visible in Types

After 42 days of building type safety into your TypeScript, you're ready to go deeper. Today we're tackling readonly and as const—two related but distinct tools that freeze your data and make immutability visible in types. When you're running production systems handling real money or critical state, the difference between a mutable object and an immutable one can mean the difference between a bug report and a production incident.

Why Immutability Matters in Large Systems

In my early projects, I built an e-commerce platform where a product price could accidentally get mutated during discount calculation. The bug was subtle: a helper function modified the original object instead of creating a copy. By the time we traced it, three orders had already been processed with wrong totals. TypeScript can't prevent runtime mutations on its own, but readonly and as const let you declare intent and catch these issues at compile time.

On a CRM system I worked on, configuration objects were getting modified in unexpected places. Without immutability markers, any function that accepted the config could change it. Using as const for our lookup tables forced us to be explicit about when modifications were allowed, making the codebase significantly easier to reason about.

readonly Properties Can't Be Reassigned

The readonly keyword marks object properties as immutable. Once set during initialization or in the constructor, they cannot be reassigned. This is your first line of defense against accidental mutations.

Consider a user model in a SaaS application:

typescript
interface User {
  readonly id: string;
  readonly email: string;
  readonly createdAt: Date;
  name: string; // Mutable by design
}

const user: User = {
  id: "usr_123",
  email: "alice@example.com",
  createdAt: new Date(),
  name: "Alice"
};

user.name = "Bob"; // OK
user.id = "usr_456"; // Error: Cannot assign to readonly property 'id'

The key insight: readonly prevents reassignment, but it does not make nested objects immutable. If you have a readonly array of objects, you can't change the array itself, but you can still modify objects inside it.

typescript
interface Order {
  readonly id: string;
  readonly items: { id: string; quantity: number }[];
}

const order: Order = {
  id: "ord_789",
  items: [{ id: "prod_1", quantity: 2 }]
};

order.items = []; // Error: Cannot assign to readonly property 'items'
order.items[0].quantity = 5; // OK—this mutates the nested object

This shallow immutability is often enough. In my ride-hailing platform, marking the trip ID and passenger ID as readonly prevented them from being accidentally changed during state transitions, even though we modified calculated fields like estimatedArrival.

ReadonlyArray Prevents push, splice, and Pop

Arrays are mutable by default. TypeScript provides ReadonlyArray to prevent modifications like push, splice, or pop. This is different from a const array—const prevents reassignment of the variable, but not mutation of contents.

typescript
// const prevents reassignment, but not mutation
const tags: string[] = ["feature", "bug"];
tags.push("urgent"); // OK
tags = []; // Error: Cannot reassign const

// ReadonlyArray prevents mutation
const readonlyTags: ReadonlyArray = ["feature", "bug"];
readonlyTags.push("urgent"); // Error: Property 'push' does not exist on type 'readonly string[]'
readonlyTags[0] = "chore"; // Error: Index signature in type 'readonly string[]' only permits reading

The shorthand syntax is readonly string[], which is equivalent to ReadonlyArray.

In a trading platform I built, we used ReadonlyArray for historical trade records. Once a trade was recorded, no calculation or helper function should accidentally modify the history. By marking it readonly, we ensured the audit trail stayed intact:

typescript
interface TradeHistory {
  readonly trades: readonly Trade[];
  readonly lastUpdated: Date;
}

function addTradeToHistory(history: TradeHistory, newTrade: Trade) {
  // history.trades.push(newTrade); // Error: ReadonlyArray has no push
  return {
    ...history,
    trades: [...history.trades, newTrade],
    lastUpdated: new Date()
  };
}

const history: TradeHistory = {
  trades: [{ id: "1", amount: 100 }],
  lastUpdated: new Date()
};

const updated = addTradeToHistory(history, { id: "2", amount: 200 });

Notice the pattern: instead of mutating, we spread into a new array. This functional style, enforced by ReadonlyArray, makes data flow explicit and side effects obvious.

as const Freezes Literal Types

The as const assertion is stronger than readonly. It tells TypeScript to treat a value as a literal type and make all nested properties readonly recursively. This is powerful for configuration objects and lookup tables.

Without as const, TypeScript widens types:

typescript
const colors = {
  primary: "#3498db",
  secondary: "#e74c3c"
};

// colors is inferred as { primary: string; secondary: string }
// You can do this:
colors.primary = "#2ecc71"; // OK—no error

With as const, types become literal and readonly:

typescript
const colors = {
  primary: "#3498db",
  secondary: "#e74c3c"
} as const;

// colors is now { readonly primary: "#3498db"; readonly secondary: "#e74c3c" }
colors.primary = "#2ecc71"; // Error: Cannot assign to readonly property 'primary'

// Type narrowing works perfectly
type ColorKey = keyof typeof colors; // "primary" | "secondary"
type ColorValue = typeof colors[ColorKey]; // "#3498db" | "#e74c3c"

This is invaluable for enums and configuration. In a travel booking system, I defined payment method constants using as const:

typescript
const PAYMENT_METHODS = {
  CREDIT_CARD: "credit_card",
  DEBIT_CARD: "debit_card",
  BANK_TRANSFER: "bank_transfer",
  WALLET: "wallet"
} as const;

type PaymentMethod = typeof PAYMENT_METHODS[keyof typeof PAYMENT_METHODS];
// PaymentMethod = "credit_card" | "debit_card" | "bank_transfer" | "wallet"

function processPayment(method: PaymentMethod, amount: number) {
  // method is now type-safe and literal
  switch (method) {
    case PAYMENT_METHODS.CREDIT_CARD:
      return chargeCard(amount);
    case PAYMENT_METHODS.WALLET:
      return debitWallet(amount);
    // TypeScript will error if you forget a case
  }
}

The real win: TypeScript catches typos and missing cases. You can't accidentally pass "credit card" (with a space) or forget a payment method in your switch statement.

Great for Config and Lookup Tables

Configuration objects and lookup tables are prime candidates for as const. They rarely change after initialization, and you want strong type safety around them.

Here's a practical example from a Next.js app I worked on:

typescript
const ENV_CONFIG = {
  development: {
    apiUrl: "http://localhost:3000",
    logLevel: "debug",
    cacheTTL: 0
  },
  production: {
    apiUrl: "https://api.example.com",
    logLevel: "warn",
    cacheTTL: 3600
  }
} as const;

type Environment = keyof typeof ENV_CONFIG;
type EnvConfig = typeof ENV_CONFIG[Environment];

function initApp(env: Environment) {
  const config = ENV_CONFIG[env];
  console.log(`Connecting to ${config.apiUrl}`);
  // config.logLevel is now "debug" | "warn", not string
  // config.cacheTTL is now 0 | 3600, not number
}

initApp("production"); // OK
initApp("staging"); // Error: Argument of type '"staging"' is not assignable

On a CRM system, I used as const for role-based permissions:

typescript
const PERMISSIONS = {
  admin: ["read", "write", "delete", "admin"],
  manager: ["read", "write"],
  user: ["read"]
} as const;

type Role = keyof typeof PERMISSIONS;
type Permission = typeof PERMISSIONS[Role][number];
// Permission = "read" | "write" | "delete" | "admin"

function hasPermission(role: Role, action: Permission): boolean {
  return PERMISSIONS[role].includes(action);
}

hasPermission("admin", "delete"); // OK
hasPermission("user", "delete"); // OK (but at runtime, returns false)

Note the last example: TypeScript doesn't validate the logic, only the types. The function will return false at runtime, which is fine. The type system just ensures you're asking sensible questions.

Common Mistakes

Mistake 1: Forgetting readonly is shallow. If you have a readonly object with a mutable property inside, that property is still mutable. You need to mark nested structures readonly too, or use a library like Immer for deep immutability.

typescript
interface Config {
  readonly settings: { theme: string }; // settings is readonly, but theme is not
}

const config: Config = { settings: { theme: "dark" } };
config.settings.theme = "light"; // OK—no error

Mistake 2: Using as const on mutable structures. If you use as const on an array with objects, the objects themselves are readonly, but you can still reassign the array if it's not in a const context.

typescript
let items = [{ id: 1, name: "Item" }] as const;
items = [{ id: 2, name: "Other" }]; // OK—reassignment is allowed

const items2 = [{ id: 1, name: "Item" }] as const;
items2 = []; // Error: Cannot reassign const

Mistake 3: Confusing readonly with const. const prevents reassignment of the variable. readonly prevents modification of properties. They work together but serve different purposes.

Mistake 4: Overusing as const in function parameters. If a function receives as const data, it can be restrictive. Sometimes you want a function to accept mutable data too. Mark readonly only where you truly need immutability.

typescript
// Too strict
function updateUser(user: { readonly name: string }) {
  // Can't accept a regular { name: string } object
}

// Better
function updateUser(user: { name: string }) {
  // Accepts both readonly and mutable
}

Key Takeaways

  • readonly marks individual properties as immutable, preventing reassignment but allowing mutations of nested objects.
  • ReadonlyArray prevents array mutations like push, splice, and pop, forcing a functional style.
  • as const freezes literal types recursively, making all nested properties readonly and preserving exact values instead of widening to general types.
  • Use as const for configuration, enums, lookup tables, and any data that should never change.
  • Remember that TypeScript immutability is shallow by default; nested objects can still be mutated unless marked readonly too.
  • Immutability in types makes intent clear to readers and catches accidental mutations at compile time, which is critical in production systems handling state and data integrity.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles