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 recursivelyreadonly
. This means that the compiler will complain if you tried to mutate a property, saving you from runtime bugs. Read about the limitations of thereadonly
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.
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
As later observed in the usage section, this library provides two methods for achieving signal immutability:
- 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. - 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.
Install using npm:
npm install ngx-signal-immutability
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()));
TypeScript's structural type system cannot prevent violations of readonly
, and therefore Immutable<T>
constraints in certain scenarios, such as:
- Type Assertions: If you use a type assertion to override the type of an object, you can potentially bypass the
readonly
orImmutable<T>
constraints. - Type Compatibility: TypeScript's structural typing means that objects with
readonly
properties are compatible with their mutable counterparts, allowing you to bypass immutability checks. - Function Arguments: If you pass a
readonly
orImmutable<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,
});
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