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:
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.
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 objectThis 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.
// 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 readingThe 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:
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:
const colors = {
primary: "#3498db",
secondary: "#e74c3c"
};
// colors is inferred as { primary: string; secondary: string }
// You can do this:
colors.primary = "#2ecc71"; // OK—no errorWith as const, types become literal and readonly:
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:
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:
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 assignableOn a CRM system, I used as const for role-based permissions:
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.
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 errorMistake 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.
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 constMistake 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.
// 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.






Comments
Loading comments…