Skip to main content
TypeScript

Mutability in JavaScript and TypeScript

10 min read
Mutability in JavaScript and TypeScript

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 than Direction.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 enum types with the same values are not assignable to each other; as const unions 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 enum consistently 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 Status42 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 constObject.freeze
When it actsCompile timeRuntime
Survives to JSNo — erasedYes — real method call
Prevents mutationOnly in the type checkerActually prevents it
Deep?Yes — recursively locks typesNo — shallow only
Affects runtime valuesNoYes

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

GoalTool
Prevent property reassignment (compile time)readonly prop: T
Make all properties of a type readonlyReadonly<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 inferencefunction f<const T>(...)
Prevent mutation at runtimeObject.freeze(...)
Deep readonly typeCustom DeepReadonly<T>
Replace enum with plain JS outputas const object + derived union type

A few things worth keeping in mind:

  • const in JavaScript prevents binding reassignment, not value mutation.
  • readonly in TypeScript is erased at compile time — it has no runtime effect.
  • Object.freeze is shallow unless you make it recursive.
  • as const is not just about immutability — it preserves type information that TypeScript would otherwise discard.
  • as const and Object.freeze solve 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.

Share

Send this article to someone.

Tags

JavaScript TypeScript Fundamentals