Skip to content

zuriscript/ngx-signal-immutability

Repository files navigation

ngx-signal-immutability

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

  • Immutability is enforced through the use of a custom type Immutable<T>, rendering all properties of the signal's value type recursively readonly. This means that the compiler will complain if you tried to mutate a property, saving you from runtime bugs. Read about the limitations of the readonly keyword in the deep freeze section
  • There is support for mutating an immutable signal in a mutable fashion through external solutions like immer.js or by employing an inherent yet less optimized built-in mutation function.

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. — Angular Discussions - Sub-RFC 2: Signal APIs

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:

  • Supports interoperability with rxjs observables, which may relay on a stream of immutable values (see buffer, shareReplay, etc.)
  • Allows for accumulation of singal emmited values over time
  • Signal consumers operate on their individual copies, effectively eliminating various potential concurrent pitfalls
  • Helps following unidirectional dataflow principle
  • More closely adheres to the principles of the functional programming paradigm, enhancing predictability in state modification (matter of taste)
  • Improved signal state change detection, since modification of a signal only then fires a changed event notification if the new value is an actual new object

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

About

Compile-time and runtime immutability for Angular signals

Resources

License

Stars

Watchers

Forks

Packages

No packages published