Immutable class generation for TypeScript — deep copying, value equality, and runtime immutability via decorators and code generation.
A TypeScript port of Dart's freezed package by Remi Rousselet.
- Motivation
- Installation
- Running the Generator
- Creating Classes
- Deep Copy
- Deep Equality
- Configuration
- Building the Library
TypeScript has no native support for immutable classes with named parameters. Achieving truly immutable data classes requires significant boilerplate:
readonlyon every propertyObject.freeze()in every constructor- Manual
copyWithmethods for creating modified copies - Manual
equalsmethods for structural comparison - Recursive freezing of nested collections
freezedts eliminates this boilerplate. You write a class with a decorator, and the generator produces an abstract base class that handles immutability, deep copying, value equality, toString(), and collection freezing.
// You write this:
@freezed()
class Person extends $Person {
constructor(params: { firstName: string; lastName: string; age: number }) {
super(params);
}
}The generator produces $Person with:
- readonly properties
Object.freeze(this)in the constructorperson.with({ age: 31 })for copyingperson.equals(other)for deep structural comparisonperson.toString()→"Person(firstName: John, lastName: Smith, age: 30)"- Recursive freezing of arrays, Maps, and Sets
npm install freezedts
npm install -D freezedts-clifreezedts is the runtime — add it as a production dependency. It has zero transitive dependencies.
freezedts-cli is the code generator — add it as a dev dependency. It depends on ts-morph for AST parsing.
Requirements: TypeScript 6, ESM, TC39 stage 3 decorators.
# Generate .freezed.ts files for all source files in the current directory
npx freezedts
# Generate for a specific directory
npx freezedts src
# Watch mode — regenerate on file changes
npx freezedts --watch
npx freezedts -w src
# Use a custom config file
npx freezedts --config path/to/freezedts.config.json
npx freezedts -c custom.json -w src
# Force generating all freezed files
npx freezedts --force
# Show help
npx freezedts --help
npx freezedts -hThe generator scans for .ts files containing @freezed() classes and produces .freezed.ts files alongside them.
Only changed files are regenerated (mtime-based).
- Create your source file (e.g.,
person.ts):
import { freezed } from 'freezedts';
import { $Person } from './person.freezed.ts';
@freezed()
class Person extends $Person {
constructor(params: { firstName: string; lastName: string; age: number }) {
super(params);
}
}- Run the generator:
npx freezedts- The generated
person.freezed.tsprovides an abstract base class$Personwithreadonlyproperties,Object.freeze(this),with(),equals(), andtoString().
Where a source file contains multiple @freezed classes, they will be generated to the same file.
import { freezed } from 'freezedts';
import { $Person, $Address } from './person.freezed.ts';
@freezed()
class Person extends $Person {
...
}
@freezed()
class Address extends $Address {
...
}Configure defaults and validation in the @freezed() decorator's fields option:
@freezed({
fields: {
port: {
default: 3000,
assert: (v: number) => v > 0 && v < 65536,
message: 'port out of range',
},
host: { default: 'localhost' },
},
})
class ServerConfig extends $ServerConfig {
constructor(params: { name: string; host?: string; port?: number }) {
super(params);
}
}
const config = new ServerConfig({ name: 'api' });
config.host; // 'localhost'
config.port; // 3000
new ServerConfig({ name: 'api', port: -1 }); // throws: "port out of range"Field config options:
| Option | Type | Description |
|---|---|---|
default |
unknown |
Default value when the parameter is undefined |
assert |
(value) => boolean |
Validation function run at construction time |
message |
string | undefined |
An optional error message when the assertion fails. If omitted, a basic error message will be generated. |
Arrays, Maps, and Sets are recursively frozen at construction time. Mutation attempts throw at runtime:
@freezed()
class Team extends $Team {
constructor(params: { name: string; members: string[]; scores: number[] }) {
super(params);
}
}
const team = new Team({ name: 'Alpha', members: ['Alice', 'Bob'], scores: [10, 20] });
team.members.push('Charlie'); // throws TypeError
team.members[0] = 'Zara'; // throws TypeErrorThe with() method creates a new frozen instance with selective property overrides:
const alice = new Person({ firstName: 'Alice', lastName: 'Smith', age: 30 });
const bob = alice.with({ firstName: 'Bob' });
// bob → Person(firstName: Bob, lastName: Smith, age: 30)
// alice is unchangedFor nested @freezed types, with supports proxy-chained deep copies:
@freezed()
class Assistant extends $Assistant {
constructor(params: { name: string }) { super(params); }
}
@freezed()
class Director extends $Director {
constructor(params: { name: string; assistant: Assistant }) { super(params); }
}
@freezed()
class Company extends $Company {
constructor(params: { name: string; director: Director }) { super(params); }
}
const co = new Company({
name: 'Acme',
director: new Director({
name: 'Jane',
assistant: new Assistant({ name: 'John' }),
}),
});
// Shallow copy
co.with({ name: 'NewCo' });
// Deep copy — update a nested freezed property
co.with.director({ name: 'Larry' });
co.with.director.assistant({ name: 'Sue' });The equals() method performs structural comparison:
const a = new Person({ firstName: 'Alice', lastName: 'Smith', age: 30 });
const b = new Person({ firstName: 'Alice', lastName: 'Smith', age: 30 });
a === b; // false (different instances)
a.equals(b); // true (same structure)Configure equality mode per class:
@freezed({ equality: 'deep' }) // default — recursive structural comparison
class DeepPerson extends $DeepPerson { ... }
@freezed({ equality: 'shallow' }) // === for primitives, .equals() for nested freezed types
class ShallowPerson extends $ShallowPerson { ... }Disable generation of specific methods via the @freezed() decorator:
@freezed({ copyWith: false }) // skip with() generation
@freezed({ equal: false }) // skip equals() generation
@freezed({ toString: false }) // skip toString() generation
@freezed({ copyWith: false, equal: false, toString: false }) // only immutabilityCreate a freezedts.config.json in your project root:
{
"freezed": {
"options": {
"format": true,
"copyWith": false,
"equal": false,
"toString": false
}
}
}All options default to true (enabled) except format which defaults to false.
Per-class @freezed() options override project-wide freezedts.config.json defaults. If neither specifies a value, the built-in default applies (all features enabled).
per-class @freezed() → freezedts.config.json → built-in defaults
(highest priority) (lowest priority)
# Install dependencies (sets up workspace symlinks)
npm install
# Build both packages
npm run build
# Run tests
npm test
# Run the generator on this project's source files
npm run generate
# Watch mode for tests
npm run test:watchThe project is structured as an npm workspace with two packages:
- freezedts — runtime library (zero dependencies)
- freezedts-cli — code generator (depends on ts-morph)
Other tools:
- TypeScript 6 with ES2022 target and Node16 module resolution
- bun:test for testing