JavaScript has a split personality when it comes to values. Primitives behave one way, objects behave another — and unless you understand why, you will spend time debugging mutations that should not have been possible. This article works through that split from the ground up: how values live in memory, what JavaScript’s let and const actually guarantee, and how TypeScript layers compile-time enforcement on top with readonly and Readonly<T>.
Two questions worth keeping in the back of your mind as you read. First: are TypeScript enums still the right default for representing a fixed set of values, or has as const quietly replaced them? Second: if as const marks everything readonly at the type level, does it actually prevent mutation at runtime — and if not, what does? Both are answered near the end, and the answers affect how you write shared constants day to day.
Primitives vs Objects
Primitives
Numbers, booleans, strings, null, undefined, Symbol, and BigInt are primitives. They are immutable by nature — you cannot change them in place, only produce new values.
let greeting = 'hello';
greeting[0] = 'H'; // silently fails (or throws in strict mode)
console.log(greeting); // "hello"
let upper = greeting.toUpperCase(); // returns a new string
console.log(greeting); // "hello" — unchanged
console.log(upper); // "HELLO"
String methods never mutate the original. They return new strings. This is why primitives are safe to pass around: whoever receives one cannot affect your copy.
Primitives are compared by value: 5 === 5 is always true.
Objects and Arrays
Objects, arrays, functions, Maps, and Sets are reference types. JavaScript allocates them on the heap and gives you a pointer to that location.
Stack Heap
─────────────────── ─────────────────────────
score │ 42 0x4a2f │ { name: "John" }
name │ "John"
user │ → 0x4a2f
alias │ → 0x4a2f
Both user and alias point to the same address. A mutation through one is visible through the other.
const user = { name: 'John' };
const alias = user;
alias.name = 'Grace';
console.log(user.name); // "Grace" — mutated through alias
Two objects with identical contents are not === unless they refer to the same address:
{ name: "John" } === { name: "John" } // false
JavaScript: let vs const
let and const control the binding — whether the variable label can be pointed at a different value. They say nothing about whether the value itself is mutable.
let count = 0;
count = 1; // fine
const MAX = 100;
MAX = 200; // TypeError: Assignment to constant variable
const config = { debug: false };
config.debug = true; // fine — the object is still mutable
config = {}; // TypeError: Assignment to constant variable
const does not make an object immutable. It only prevents reassignment of the variable. The object’s properties remain freely writable. This is a common source of confusion.
This distinction is enforced at runtime.
TypeScript: readonly
TypeScript enforces constraints at compile time. The generated JavaScript contains no trace of readonly — it is erased during compilation. The value is that TypeScript catches violations before the code runs.
On Object Properties
type Config = {
readonly endpoint: string;
readonly timeout: number;
};
const cfg: Config = { endpoint: '/api', timeout: 5000 };
cfg.endpoint = '/v2'; // Error: Cannot assign to 'endpoint' because it is a read-only property
On Arrays
A plain string[] has push, pop, splice, sort, and other mutating methods. Marking it readonly removes those from the type.
const tags: readonly string[] = ['typescript', 'web'];
tags.push('new'); // Error: Property 'push' does not exist on type 'readonly string[]'
tags[0] = 'other'; // Error: Index signature in type 'readonly string[]' only permits reading
const newTags = [...tags, 'new']; // spread produces a new array — fine
ReadonlyArray<string> is equivalent. Use whichever reads more clearly in context.
On Function Parameters
Marking a parameter readonly documents the intent and prevents the function from accidentally mutating its input.
function formatUser(user: Readonly<{ name: string; age: number }>) {
user.name = 'Bob'; // Error: Cannot assign to 'name' because it is a read-only property
return `${user.name}, age ${user.age}`;
}
This is useful when writing utilities or library functions — callers can trust their objects will not be modified.
Readonly<T>
The built-in utility type Readonly<T> marks every direct property of T as readonly. It is shallow — nested objects are not affected.
type User = { name: string; address: { city: string } };
type FrozenUser = Readonly<User>;
// type of FrozenUser:
// { readonly name: string; readonly address: { city: string } }
// ^ still mutable
For deep immutability, you need a recursive type:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
TypeScript does not ship this built-in, but it is straightforward to define.
TypeScript: const Type Parameter (TS 5.0+)
When TypeScript infers a type argument for a generic function, it widens literal types by default:
function createTuple<T>(values: T[]): T[] {
return values;
}
const t = createTuple(['a', 'b', 'c']);
// inferred: string[] — the literals "a", "b", "c" are gone
Adding const to the type parameter tells TypeScript to preserve literal types during inference:
function createTuple<const T>(values: T[]): T[] {
return values;
}
const t = createTuple(['a', 'b', 'c']);
// inferred: ("a" | "b" | "c")[] — literals preserved
This matters when building type-safe registries, routing tables, or configuration schemas where the actual string values need to survive into the type system.
TypeScript: as const
By default, TypeScript widens object and array literals:
const direction = 'left';
// type: "left" — primitives infer as literals with const
const options = { direction: 'left' };
// type: { direction: string } — widened to string
The as const assertion tells TypeScript to infer the most specific type possible and mark everything as readonly:
const COLORS = {
red: '#c8381a',
blue: '#2a5f8f',
gold: '#b8960c',
} as const;
// type of COLORS:
// { readonly red: "#c8381a"; readonly blue: "#2a5f8f"; readonly gold: "#b8960c" }
type ColorKey = keyof typeof COLORS; // "red" | "blue" | "gold"
type ColorValue = (typeof COLORS)[ColorKey]; // "#c8381a" | "#2a5f8f" | "#b8960c"
Arrays with as const
const METHODS = ['GET', 'POST', 'DELETE'] as const;
// type of METHODS: readonly ["GET", "POST", "DELETE"] — a tuple, not string[]
type Method = (typeof METHODS)[number]; // "GET" | "POST" | "DELETE"
function request(url: string, method: Method) {
/* ... */
}
request('/api', 'PATCH'); // Error: Argument of type '"PATCH"' is not assignable to parameter of type '"GET" | "POST" | "DELETE"'
Deep Immutability with as const
as const is deeply recursive on the type level. Every nested object and array gets marked readonly all the way down — unlike Readonly<T>, which is shallow.
const CONFIG = {
server: {
host: 'localhost',
ports: [3000, 4000],
},
} as const;
// type of CONFIG:
// {
// readonly server: {
// readonly host: "localhost";
// readonly ports: readonly [3000, 4000];
// }
// }
CONFIG.server.host = 'prod'; // Error: Cannot assign to 'host' because it is a read-only property
CONFIG.server.ports[0] = 8080; // Error: Cannot assign to '0' because it is a read-only property
as const vs enum
TypeScript has a built-in enum construct, but many codebases avoid it in favour of as const objects. The difference is worth understanding explicitly.
An enum compiles to a real JavaScript object wrapped in an IIFE. A const enum is inlined at each use site and erased entirely. Neither looks like normal JavaScript, and both have edge cases:
enum Direction {
Left = 'left',
Right = 'right',
}
// compiles to:
var Direction;
(function (Direction) {
Direction['Left'] = 'left';
Direction['Right'] = 'right';
})(Direction || (Direction = {}));
The as const equivalent produces no runtime overhead and compiles away completely:
const Direction = {
Left: 'left',
Right: 'right',
} as const;
type Direction = (typeof Direction)[keyof typeof Direction];
// "left" | "right"
// compiles to:
const Direction = { Left: 'left', Right: 'right' };
// no wrapper, no IIFE
When as const is the better choice:
- The values are plain strings or numbers you want to use directly —
"left"rather thanDirection.Left - You want the union type to stay in sync automatically — add a key to the object and the union updates without a separate edit
- You are publishing a library and want the output to be readable, idiomatic JavaScript
- You need to derive both key types and value types separately with
keyof typeof
When enum might still make sense:
- You need nominal typing — two different
enumtypes with the same values are not assignable to each other;as constunions are structural and will overlap - You need a numeric enum with auto-incrementing values and the compiled output is not a concern
- The codebase already uses
enumconsistently and diverging would introduce more noise than it removes
So are enums still the right default? For most use cases, no. The typescript-eslint ruleset includes a no-enum rule precisely because as const covers the same ground with less compiled output and fewer edge cases. Numeric enums in particular have a well-known quirk: enum Status { Active, Inactive } accepts any number as a valid Status — 42 passes the type check. as const does not have this problem because the union type is derived from the actual declared values.
The one scenario where enum still has a clear edge is nominal typing. If you have two separate enum types that happen to contain the same values, TypeScript will not let them cross-assign. With as const unions, two types with the same string members are structurally identical and will freely overlap. Whether that matters depends on the domain — for most application code it does not, but for a library with a strict public API it can.
Object.freeze — Runtime Enforcement
readonly and as const exist only in TypeScript’s type system. The compiled JavaScript has no memory of them. If you need enforcement at runtime — for example in a library consumed without TypeScript, or in tests — use Object.freeze.
const config = Object.freeze({
endpoint: '/api',
timeout: 5000,
});
config.endpoint = '/v2'; // silently ignored, or throws in strict mode
config.newProp = 'x'; // same
TypeScript understands Object.freeze and automatically types its return as Readonly<T>, so you get both runtime protection and compile-time checking from a single call.
Like Readonly<T>, Object.freeze is shallow:
const state = Object.freeze({
user: { name: 'John' },
});
state.user = {}; // fails — frozen
state.user.name = 'Grace'; // succeeds — nested object is not frozen
To freeze deeply, recursively freeze every nested object, or use a library like Immer that manages immutable updates through structural sharing.
as const vs Object.freeze
These two are frequently conflated because both produce something that feels “locked”. They are not interchangeable.
as const | Object.freeze | |
|---|---|---|
| When it acts | Compile time | Runtime |
| Survives to JS | No — erased | Yes — real method call |
| Prevents mutation | Only in the type checker | Actually prevents it |
| Deep? | Yes — recursively locks types | No — shallow only |
| Affects runtime values | No | Yes |
as const is a type-level annotation. It tells TypeScript to narrow the inferred type and treat all properties as readonly, but the JavaScript that runs has no knowledge of it. You can still mutate the object from a .js file, or through any.
Object.freeze is a real runtime operation. The frozen object resists mutation even in plain JavaScript with no type system involved.
The two are most useful combined:
const CONFIG = Object.freeze({
apiUrl: 'https://api.example.com',
timeout: 3000,
} as const);
// compile-time: readonly, literal types preserved
// runtime: mutation throws in strict mode
as const preserves the literal types so TypeScript can check values precisely. Object.freeze ensures the guarantee holds even outside the type system. Using both is the most thorough approach for module-level constants.
Summary
| Goal | Tool |
|---|---|
| Prevent property reassignment (compile time) | readonly prop: T |
| Make all properties of a type readonly | Readonly<T> |
| Prevent array mutation (compile time) | readonly T[] or ReadonlyArray<T> |
| Preserve literal types in an object or array | { ... } as const |
| Preserve literal types in generic inference | function f<const T>(...) |
| Prevent mutation at runtime | Object.freeze(...) |
| Deep readonly type | Custom DeepReadonly<T> |
| Replace enum with plain JS output | as const object + derived union type |
A few things worth keeping in mind:
constin JavaScript prevents binding reassignment, not value mutation.readonlyin TypeScript is erased at compile time — it has no runtime effect.Object.freezeis shallow unless you make it recursive.as constis not just about immutability — it preserves type information that TypeScript would otherwise discard.as constandObject.freezesolve different problems and are most useful when combined.
The practical default is to mark things readonly as early as possible, pass objects as Readonly<T> into functions that should not modify them, and reach for as const whenever you have a fixed set of values that need to flow through the type system.