Home

Awesome

<p align="center"> <img width="22%" height="22%" src="logo.png"> </p>

ngx-signal-immutability

A lightweight library introducing compile-time immutability and optionally runtime object deep freezing to Angular signals:

Motivation

At the moment, there is no real immutability support for signals out-of-the-box, eventhough there had been experiments in the past. This is unsurprising, given the broad spectrum of applications that signals are intended for:

We specifically didn’t want to "pick sides" in the mutable / immutable data discussion and designed the signal library (and other Angular APIs) so it works with both. — <cite>Angular Discussions - Sub-RFC 2: Signal APIs</cite>

However, this can potentially result in unexpected behaviors and challenging-to-debug errors. For instance: We might modify a signal containing a reference-type value from any location without utilizing set, update, mutate. This is actually possible without retaining the reference to the actual WritableSignal, hence operaiting on a supposedly readonly signal.

const sig = signal({ something: 'initial' }).asReadonly();
sig().something = 'new';
console.log(sig()); // prints "{ something: 'new' }"

Another reason for this library is to bring the many advantages of immutability into the world of signals:

Performance Considerations

As later observed in the usage section, this library provides two methods for achieving signal immutability:

  1. The first method involves making a Writable Signal immutable, resulting in the creation of a new object with every mutation. While this approach may introduce a potential performance dip in computationally intensive scenarios, its advantages, as listed above, often outweigh this concern, particularly when considering signals as part of the application state.
  2. Alternatively, derived signals can be made immutable without affecting the source (writable) signal, thus eliminating the need for immutable updates. This ensures that consumers without access to the source signal cannot modify its value. In this case, the only expected overhead would relate to deep freezing, which can be disabled for production purposes.

As a rule of thumb: If you find yourself mutating signal values within computationally intensive procedures, such as deep within deeply nested for-loops, you may want to avoid using writable immutable signals. However, it often makes complete sense to employ fully immutable signals or, at the very least, opt for the second variant, which involves making derived signals immutable.

Installation

Install using npm:

npm install ngx-signal-immutability

Usage

Basic

Create an immutable WritableSignal that can be mutated in a mutable fashion:

import { immutableSignal } from 'ngx-signal-immutability';

const sig = immutableSignal({ count: 0 });

// Api is the same as a regular writable signal
// Apply a mutation
sig.mutate(currentState => {
  currentState.count += 1;
});

// Apply an update
sig.update(current => ({
  count: current.count + 1,
}));

// Set a new State
sig.set({ count: 3 });

// It saves you from:
sig().count = 5; // Compile-time Error

sig.update(current => {
  current.count = 2; // Compile-time Error
  return current;
});

// And this won't fire a changed notification envet:
sig.set(sig());
sig.update(current => current);

Make any signal immutable

import { computed, signal } from '@angular/core';
import { immutable } from 'ngx-signal-immutability';

const sig = signal({ count: 0 });

const immutableSig1 = immutable(sig);
const immutableSig2 = immutable(sig.asReadonly());
const immutableSig3 = immutable(computed(() => sig()));

Deep Freezing

TypeScript's structural type system cannot prevent violations of readonly, and therefore Immutable<T> constraints in certain scenarios, such as:

  1. Type Assertions: If you use a type assertion to override the type of an object, you can potentially bypass the readonly or Immutable<T> constraints.
  2. Type Compatibility: TypeScript's structural typing means that objects with readonly properties are compatible with their mutable counterparts, allowing you to bypass immutability checks.
  3. Function Arguments: If you pass a readonly or Immutable<T> object to a function that accepts the non-readonly version of the type, the immutability type constraints are not enforced within the function's scope.

By deep freezing the state, you can attain runtime immutability assurance against direct modifications. However, it's important to acknowledge that this introduces some performance overhead. Consequently, it's particularly well-suited for use during development and debugging stages, read more.

import { signal } from '@angular/core';
import {
  immutable,
  immutableSignal,
  setGlobalImmutableSignalOptions,
} from 'ngx-signal-immutability';

// Option 1: Enable deep freezing globally
setGlobalImmutableSignalOptions({ enableDeepFreezing: true });
const writableImmutableSig1 = immutableSignal({ count: 0 }); // Any update of the value is automatically freezed
const immutableSig1 = immutable(signal({ count: 0 })); // Any derived value is automatically freezed

// Option 2: Enable deep freezing for specifc immutable signals
const writableImmutableSig2 = immutableSignal(
  { count: 0 },
  { enableDeepFreezing: true }
);
const immutableSig2 = immutable(signal({ count: 0 }), {
  enableDeepFreezing: true,
});

Immer.js

When no specific strategy is provided, a basic Clone-and-Mutate approach is employed, leveraging structuredClone - or JSON Parse/Stringify as alternative if structuredClone is unsupported. While this method might be acceptable for various scenarios, it's advisable to consider integrating a specialized library like immer.js for more robustness and speed.

import {
  immutableSignal,
  setGlobalImmutableSignalOptions,
} from 'ngx-signal-immutability';
import { produce } from 'immer';

// Option 1: Set global mutation function
setGlobalImmutableSignalOptions({
  mutationProducerFn: produce,
});

// Option 2: Set mutation func for specific immutable writable signal
const immutableSig = immutableSignal(
  { count: 0 },
  { mutationProducerFn: produce }
);

Built with ❤️ by zuriscript