TL;DR (Quick Summary)#
- Immutability is Mandatory: In Functional Architecture, you never modify an existing object or array in memory. You create a copy, apply changes to the copy, and return the new data structure.
- The
readonlyKeyword: TypeScript provides compile-time protection against property mutation. Use it on every interface property. - Deep Freezing: Use
as constto lock down literal configuration objects completely, preventing any nested modifications. - Array Transformations: Never use
.push(),.pop(), or.sort(). Use[...arr, item],.filter(), and.map()to return new arrays.
1. Introduction: The Hidden Danger of Shared Mutable State#
In standard JavaScript (and therefore TypeScript), all objects and arrays are passed by reference. If you pass an object into a function and modify a property, you are mutating the original object sitting in system memory.
Consider a backend service processing a user’s shopping cart.
// 🔴 DANGER: Mutating the original object reference!
type Cart = { id: string; total: number; checkedOut: boolean };
function checkoutCart(cart: Cart): Cart {
cart.checkedOut = true; // Mutation occurs here
return cart;
}
const userCart: Cart = { id: "cart_123", total: 150, checkedOut: false };
// We pass the cart to the checkout function...
checkoutCart(userCart);
// The original variable 'userCart' has been permanently changed!
console.log(userCart.checkedOut); // true
In a small, single-threaded script, this might seem perfectly fine. However, in a complex, highly concurrent application where multiple components, database synchronizers, or background fibers share access to userCart, this hidden mutation will cause massive, impossible-to-trace bugs.
If a background job reads userCart exactly 1 millisecond after checkoutCart mutates it, the background job might assume the cart is fully processed before the database has actually committed the transaction.
2. Immutable Transformations: The Spread Operator#
Instead of modifying the original object, the functional paradigm demands that we copy the object, apply our changes to the copy, and return the new object. The original data structure remains completely untouched.
We achieve this in modern TypeScript using the spread operator (...).
function checkoutCartImmutable(cart: Readonly<Cart>): Cart {
// 🟢 We return a brand new object in memory.
// We spread the old properties, and override 'checkedOut'.
return {
...cart,
checkedOut: true
};
}
const originalCart: Readonly<Cart> = { id: "cart_123", total: 150, checkedOut: false };
const updatedCart = checkoutCartImmutable(originalCart);
// The original is untouched!
console.log(originalCart.checkedOut); // false
console.log(updatedCart.checkedOut); // true
Because originalCart and updatedCart occupy entirely different physical spaces in memory, a UI framework like React can compare them instantly (originalCart === updatedCart) and know exactly when to trigger a re-render without scanning every nested property.
3. Step-by-Step: Enforcing Immutability at Compile Time#
We shouldn’t rely on developers simply “remembering” not to mutate data; we should force the TypeScript compiler to scream at them if they try.
Let’s implement strict immutability in an enterprise application step-by-step.
Step 1: The readonly Modifier#
Whenever you define an interface or a type alias in a functional TypeScript codebase, it is best practice to mark every property as readonly.
// Step 1: Define the strict interface
interface AppState {
readonly activeUsers: number;
readonly requestLog: readonly string[]; // Notice the readonly array!
}
const state: AppState = { activeUsers: 0, requestLog: [] };
// 🔴 Compiler Error: Cannot assign to 'activeUsers' because it is a read-only property.
// state.activeUsers = 1;
// 🔴 Compiler Error: Property 'push' does not exist on type 'readonly string[]'.
// state.requestLog.push("GET /api/v1/health");
Step 2: The Readonly<T> Utility#
If you are importing third-party interfaces that lack the readonly modifier, you can wrap them in TypeScript’s built-in Readonly<T> utility type to strip away their mutability at the boundary of your application.
import { ExternalConfig } from "some-npm-library";
// Step 2: Wrap external mutable types
function processConfig(config: Readonly<ExternalConfig>) {
// config properties are now locked inside this function
}Step 3: Deep Immutability with as const#
When defining literal data structures (like configuration objects, HTTP headers, or specific API payloads), you can freeze them deeply by appending the as const assertion. This converts standard types (like string) into specific literal types, and applies readonly to every nested level recursively.
// Step 3: Deep freeze configurations
const AppConfig = {
api: {
endpoint: "https://api.rhidayat.work",
timeout: 5000
},
retries: 3
} as const;
// 🔴 Compiler Error: The entire object tree is locked.
// AppConfig.api.timeout = 10000;
4. Mutable vs Immutable Array Operations#
Because you cannot use mutable array methods like .push(), .pop(), or .splice() on a readonly Array<T>, functional programmers must rely heavily on immutable array transformations.
Here is a practical breakdown of how to translate common mutable operations into functional immutable operations.
| Goal | Mutable Approach (Bad) | Immutable Approach (Good) |
|---|---|---|
| Add Item | arr.push(item) | [...arr, item] |
| Add to Start | arr.unshift(item) | [item, ...arr] |
| Remove Item | arr.splice(index, 1) | arr.filter(x => x.id !== targetId) |
| Update Item | arr[index].status = 'done' | arr.map(x => x.id === id ? { ...x, status: 'done' } : x) |
| Sort Array | arr.sort((a,b) => a - b) | [...arr].sort((a,b) => a - b) |
5. Why Immutability Matters for Effect-TS#
In advanced functional runtimes like Effect-TS, data structures are passed around constantly between services, error channels, and asynchronous boundaries (Fibers).
If one piece of the pipeline mutates the data while another Fiber is reading it, the entire predictability of the runtime engine collapses. By enforcing readonly types everywhere, you guarantee that your application state flows beautifully through the pipeline without ever being corrupted.
Furthermore, Effect-TS heavily utilizes pattern matching and Discriminated Unions to handle branching logic. If the discriminant properties could be mutated mid-flight, the compiler could no longer guarantee type safety across branches.
6. Troubleshooting & Common Errors#
When converting a legacy OOP codebase to a strict functional architecture, you will inevitably encounter TypeScript compiler errors. Here is how to resolve the most common ones.
Error 1: The readonly property assignment error#
TS2540: Cannot assign to 'userId' because it is a read-only property.The Cause: You are attempting to modify a property on an interface that has been correctly marked as readonly.
The Fix: Use the spread operator to create a new object instead of modifying the existing one.
// 🔴 Bad
user.userId = "new_id";
// 🟢 Good
const updatedUser = { ...user, userId: "new_id" };Error 2: Missing Array Methods#
TS2339: Property 'push' does not exist on type 'readonly string[]'.The Cause: You defined an array as readonly string[] (which is correct), but then attempted to use a mutating method like .push() or .splice().
The Fix: Use array spread syntax to return a new array.
// 🔴 Bad
usersArray.push("Rachmat");
// 🟢 Good
const newUsersArray = [...usersArray, "Rachmat"];Error 3: Shallow Spread Mutation#
The Mistake: Believing that the spread operator (...) creates a deep copy. It does not. It only creates a shallow copy. If you have nested objects, spreading the top level does not protect the nested levels from mutation!
const original = { level1: { name: "A" } };
// 🔴 Bad: Shallow Copy
const copy = { ...original };
copy.level1.name = "B"; // This STILL mutates the original!
// 🟢 Good: Deep Copy (or better yet, use strict readonly interfaces)
const deepCopy = {
...original,
level1: { ...original.level1, name: "B" }
};Summary & Next Steps#
In this episode:
- We identified the critical dangers of mutating shared object references in memory.
- We used the spread operator to perform structural copying and immutable updates.
- We locked down interfaces and arrays at compile time using the
readonlymodifier. - We froze literal configurations deeply using
as const. - We mapped out immutable alternatives for every common mutating array method.
Now that we know how to handle data immutably, how do we structure complex conditional data in a type-safe way without resorting to massive, confusing inheritance chains?
In Episode 58: Algebraic Data Types (ADTs), we will learn the ultimate pattern for modeling domain logic!

