Skip to content

jamesgorman2/freezedts

Repository files navigation

freezedts

npm version Node.js CI CodeQL

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.

Table of Contents

Motivation

TypeScript has no native support for immutable classes with named parameters. Achieving truly immutable data classes requires significant boilerplate:

  • readonly on every property
  • Object.freeze() in every constructor
  • Manual copyWith methods for creating modified copies
  • Manual equals methods 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 constructor
  • person.with({ age: 31 }) for copying
  • person.equals(other) for deep structural comparison
  • person.toString()"Person(firstName: John, lastName: Smith, age: 30)"
  • Recursive freezing of arrays, Maps, and Sets

Installation

npm install freezedts
npm install -D freezedts-cli

freezedts 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.

Running the Generator

# 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 -h

The generator scans for .ts files containing @freezed() classes and produces .freezed.ts files alongside them.

Only changed files are regenerated (mtime-based).

Creating Classes

Basic Usage

  1. 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);
  }
}
  1. Run the generator:
npx freezedts
  1. The generated person.freezed.ts provides an abstract base class $Person with readonly properties, Object.freeze(this), with(), equals(), and toString().

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 {
  ...
}

Field Configuration

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.

Collections

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 TypeError

Deep Copy

The 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 unchanged

For 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' });

Deep Equality

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 { ... }

Configuration

Per-Class Configuration

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 immutability

Project-Wide Configuration

Create 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.

Resolution Order

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)

Building the Library

# 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:watch

The 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

About

A code generator for immutable Typescript classes

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors