~9 min de lecture
Ton Signal<T> public n'est pas readonly : le bug runtime que TypeScript te cache
Tu as un service qui expose un state via un signal. Tu penses à l'encapsulation, donc tu mets readonly sur la propriété. Tu la types en Signal<User | null> histoire de bien dire "regarde mais ne touche pas". Tu ouvres la PR, personne ne bronche.
Sauf que trois semaines plus tard, un composant écrit (this.userStore.user as WritableSignal<User | null>).set(null) pour "reset le user au logout". Une ligne, un cast que personne ne relit en review. Ça compile. Ça marche. Et ton store centralisé qui devait être la source de vérité vient de sauter parce que n'importe qui peut écrire dedans.
Le problème n'est pas dans ton composant, il est dans ton service. readonly TypeScript et le type Signal<T> sont deux faux amis qui te font croire à une protection qui n'existe qu'au design-time (au moment où tu écris le code), pas au runtime (quand il s'exécute). Les vrais verrous côté runtime, c'est asReadonly() pour exposer un signal, et computed() pour exposer une vue dérivée. Il faut savoir pourquoi.
Valide Angular 17+ (signals stables). Les APIs
signal(),computed(),asReadonly()sont dans@angular/core.
Le bug qu'on ne voit pas venir
Un UserStore classique. Rien de suspect à la lecture.
import { Injectable, Signal, signal } from '@angular/core';
export type User = { id: string; name: string; role: 'admin' | 'user' };
@Injectable({ providedIn: 'root' })
export class UserStore {
private readonly userSignal = signal<User | null>(null);
readonly user: Signal<User | null> = this.userSignal;
login(user: User): void {
this.userSignal.set(user);
}
logout(): void {
this.userSignal.set(null);
}
}
Tu l'exposes en Signal<User | null>. Le type interdit .set() et .update(). readonly sur la propriété interdit la réassignation. Deux verrous TypeScript. Deux fausses garanties.
Voilà le composant qui casse tout :
import { ChangeDetectionStrategy, Component, WritableSignal, inject } from '@angular/core';
import { UserStore, type User } from './user-store';
@Component({
selector: 'app-profile-actions',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<button (click)="reset()">Reset</button>`,
})
export class ProfileActions {
private readonly store = inject(UserStore);
reset(): void {
(this.store.user as WritableSignal<User | null>).set(null);
}
}
Un cast, un .set(), et le state central est écrasé depuis un composant feuille. TypeScript n'a pas hurlé. ESLint non plus. Ta gate de review non plus, parce que le cast est isolé sur une ligne discrète.
Le bug de fond, c'est qu'au runtime, user est encore un WritableSignal. Le typage Signal<User | null> est effacé à la compilation : TypeScript vérifie la forme du type déclaré au moment où tu écris le code, puis n'émet aucune vérification correspondante dans le JavaScript produit. .set() et .update() sont toujours là, prêts à être appelés dès qu'on lève le voile TypeScript.
Faux ami numéro 1 : le mot-clé readonly de TypeScript
readonly sur une propriété de classe interdit la réassignation de la propriété elle-même :
export class UserStore {
readonly userSignal = signal<User | null>(null);
bad(): void {
this.userSignal = signal<User | null>(null); // ❌ TS2540 (readonly)
this.userSignal.set(null); // ✅ compile
}
}
C'est utile pour empêcher de remplacer l'instance du signal par une autre. Ça ne dit rien de ce qu'on peut faire avec le signal une fois qu'on l'a récupéré. Un WritableSignal reste un WritableSignal, readonly ou pas.
Confusion vieille comme JavaScript : readonly protège la référence, pas le contenu. Comme const x = [] qui interdit x = [] mais autorise x.push(1).
Faux ami numéro 2 : caster en Signal<T> à l'export
Le type Signal<T> n'a pas .set() ni .update(), seulement l'appel (). Donc typer la propriété exposée en Signal<T> bloque l'écriture au compilateur :
readonly user: Signal<User | null> = this.userSignal; // WritableSignal typé Signal
Sauf que les types TypeScript n'existent qu'à la compilation : ils disparaissent du JavaScript produit, le runtime est intact. Un cast explicite, un as any, un as unknown as WritableSignal, une lib externe qui reçoit ton signal typé large, un test qui contourne le typage, et .set() redevient dispo.
Pire, l'IDE ne t'aide pas à voir le vrai type. Tu vois Signal<User | null> partout, tu oublies qu'à l'intérieur c'est écrit WritableSignal. Le prochain dev qui reprend le code fait la même supposition que toi. Le contrat est menteur.
Les vecteurs d'attaque les plus courants sont anodins en review. Un helper générique typé large qui accepte les deux formes :
// util/reset-signal.ts
import type { WritableSignal } from '@angular/core';
export function resetTo<T>(sig: WritableSignal<T>, value: T): void {
sig.set(value);
}
// dans un composant, personne ne tique
resetTo(this.store.user as WritableSignal<User | null>, null);
Un as any explicite pour "juste débloquer un truc" (celui-ci se fait repérer par une config ESLint stricte, mais rien ne l'empêche de passer une review humaine pressée) :
(this.store.user as any).set(null);
Ou un @ts-expect-error posé pour un test et jamais retiré. Aucune de ces lignes ne se voit dans un diff bruyant. Toutes marchent, parce que l'objet derrière store.user est encore le même WritableSignal qu'à la construction du service.
Le vrai verrou : asReadonly()
asReadonly() fait quelque chose de fondamentalement différent des deux protections précédentes : il renvoie un nouvel objet signal qui n'expose plus .set() ni .update(). Pas seulement au niveau du type, au niveau du runtime. La méthode n'existe pas sur l'objet retourné.
import { Injectable, Signal, signal } from '@angular/core';
type User = { id: string; name: string; role: 'admin' | 'user' };
@Injectable({ providedIn: 'root' })
export class UserStore {
private readonly userSignal = signal<User | null>(null);
readonly user: Signal<User | null> = this.userSignal.asReadonly();
login(user: User): void {
this.userSignal.set(user);
}
logout(): void {
this.userSignal.set(null);
}
}
Refais le composant hostile :
reset(): void {
(this.store.user as WritableSignal<User | null>).set(null);
// TypeError: this.store.user.set is not a function
}
Ce n'est plus un warning TypeScript ignoré, c'est un crash runtime pour l'écriture directe. Le contrat n'est plus une convention gentiment demandée au consommateur, c'est une propriété du store, avec une limite importante à connaître (voir plus bas).
Détail utile : le signal readonly reste réactif, il notifie ses consommateurs à chaque écriture qui change la valeur (même logique de comparaison que pour n'importe quel signal, voir cet article sur equal()). Tu n'as rien perdu en réactivité côté lecture, tu as juste retiré l'API d'écriture publique.
Attention à ce que asReadonly() ne couvre pas. Il retire .set() et .update(), pas la mutabilité de la valeur elle-même. Si le signal contient un objet ou un tableau, rien n'empêche un consommateur de le muter en place, sans .set(), sans cast, sans erreur :
cart.items().push({ id: 'hacked', price: 999, qty: 1 });
// aucun .set, aucun cast, aucun TypeError : le tableau interne du store est modifié
Le JSDoc d'Angular le dit explicitement : les signaux readonly n'ont aucun mécanisme intégré empêchant la mutation profonde de leur valeur. asReadonly() ferme le point d'entrée .set()/.update() public, pas l'accès en écriture au contenu s'il est lui-même mutable. La vraie protection : ne jamais muter en place dans tes propres méthodes ([...current, item], jamais current.push(item)), et exposer des copies si tu veux fermer aussi le trou côté consommateur.
Le pattern complet : privé writable, public readonly, méthodes de mutation
Une fois le principe posé, le pattern à généraliser dans tes services :
import { Injectable, computed, signal } from '@angular/core';
type CartItem = { id: string; price: number; qty: number };
@Injectable({ providedIn: 'root' })
export class Cart {
private readonly itemsSignal = signal<CartItem[]>([]);
readonly items = this.itemsSignal.asReadonly();
readonly total = computed(() =>
this.items().reduce((sum, item) => sum + item.price * item.qty, 0),
);
readonly isEmpty = computed(() => this.items().length === 0);
add(item: CartItem): void {
this.itemsSignal.update((current) => [...current, item]);
}
remove(id: string): void {
this.itemsSignal.update((current) => current.filter((i) => i.id !== id));
}
clear(): void {
this.itemsSignal.set([]);
}
}
Trois règles pour un service de state :
- Le
WritableSignalreste privé. SuffixeSignal, préfixe_ou justeprivate, choisis ta convention mais tiens-la. - L'exposition publique passe par
asReadonly()oucomputed(). Les deux retirent l'API d'écriture, pas la mutabilité de la valeur : si tu exposes un tableau ou un objet, protège-le aussi en écriture (copies dans tes méthodes de mutation, jamais de mutation en place).computed()aide seulement si la fonction calculée renvoie des primitives ou de vraies copies, pas la référence source telle quelle. - La mutation ne se fait qu'à travers des méthodes du service. Tes méthodes deviennent le seul chemin d'écriture. Si tu veux ajouter de la validation, de la persistance, du logging, tu as un endroit pour le faire.
Ce n'est pas un pattern nouveau. C'est le même que tu appliquais avec BehaviorSubject (le "sujet" RxJS qui expose une valeur courante à ses abonnés) + asObservable(), ou avec un getter TypeScript qui renvoyait une copie ; le détail de cette migration est dans Migrer de BehaviorSubject vers les signals. Les signals n'ont rien inventé sur ce plan ; ils ont juste rendu la version cassée (le WritableSignal exposé nu) tellement facile à écrire qu'elle est devenue la valeur par défaut dans les codebases.
Le cas particulier de linkedSignal()
linkedSignal(), ajouté en Angular 19, retourne aussi un WritableSignal. Même piège, même solution : si tu exposes une valeur produite par linkedSignal(), encapsule-la exactement pareil.
import { Injectable, linkedSignal, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class Filters {
private readonly categorySignal = signal<'all' | 'books' | 'games'>('all');
readonly category = this.categorySignal.asReadonly();
private readonly pageSignal = linkedSignal({
source: this.categorySignal,
computation: () => 1, // reset la page à chaque changement de catégorie
});
readonly page = this.pageSignal.asReadonly();
setCategory(value: 'all' | 'books' | 'games'): void {
this.categorySignal.set(value);
}
setPage(page: number): void {
this.pageSignal.set(page);
}
}
Pareil pour toSignal() : la valeur retournée est déjà un Signal<T> en lecture seule au runtime (pas de .set/.update sur l'objet). Mais si tu la réenveloppes dans un signal writable pour ajouter une valeur par défaut, encapsule ce signal writable.
Ce que tu perds si tu ne l'appliques pas
Le pattern paraît coûteux à généraliser service par service. Il l'est beaucoup moins que la dette qu'il évite.
Sans encapsulation, tu ouvres trois portes en même temps. La première, la plus visible : n'importe quel composant peut muter le state, donc n'importe quel bug de state ("pourquoi mon user courant est-il null alors qu'il vient de se logger ?") devient une chasse au trésor à travers l'application entière. Tu ne peux plus dire "l'écriture se fait ici". Elle peut être partout.
La deuxième, plus insidieuse : le refactor de ton service devient impossible sans casser du code appelant. Le jour où tu veux passer d'un signal<User> à un computed() qui reconstruit User depuis d'autres bouts de state, tu casses tous les composants qui faisaient .set() dessus. Avec l'encapsulation, ce genre de bascule ne touche que le service.
La troisième, celle qui pique le plus en review : tu écris tes non-régressions à l'envers. Chaque fois qu'un bug d'écriture sauvage explose, tu ajoutes un test qui vérifie que "ce composant précis ne mute pas ce signal précis". Tu multiplies les tests spécifiques pour prouver l'absence de mutation, au lieu d'avoir un seul test qui prouve que le point d'entrée .set() public n'existe pas.
Le coût de mettre asReadonly() sur chaque signal exposé est mesurable en secondes. Le coût de ne pas le mettre se paie en heures de debug et en refactos bloqués.
Le test qui verrouille le contrat
L'encapsulation n'est vraie qu'à la condition que personne, jamais, ne remette un .set() sur le chemin. Un test unitaire d'une ligne suffit à faire de cet invariant une vérité que la CI vérifie :
import { TestBed } from '@angular/core/testing';
import { UserStore } from './user-store';
describe('UserStore', () => {
it('exposes user as a truly read-only signal (no .set at runtime)', () => {
const store = TestBed.inject(UserStore);
expect((store.user as unknown as { set?: unknown }).set).toBeUndefined();
});
});
Un cast contrôlé, une assertion sur l'absence de .set au runtime, et n'importe quel dev qui décide un jour d'exposer directement le signal privé casse la CI. Le contrat est verrouillé au niveau du framework de test, pas au niveau du typage TS que le prochain cast ira contourner.
Si tu veux être exhaustif, vérifie aussi .update :
it('exposes user with no write API at runtime', () => {
const store = TestBed.inject(UserStore);
const user = store.user as unknown as { set?: unknown; update?: unknown };
expect(user.set).toBeUndefined();
expect(user.update).toBeUndefined();
});
Les deux méthodes sont posées comme propriétés propres de la fonction retournée par signal(). La fonction retournée par asReadonly() est nue : ni .set, ni .update. Vérifier l'absence d'une des deux te dit que tu tiens bien le signal readonly.
Généralise ce test à chaque signal public de chaque service qui expose du state. Un petit helper te rend ça trivial :
function expectReadonly(sig: unknown): void {
expect((sig as { set?: unknown; update?: unknown }).set).toBeUndefined();
expect((sig as { set?: unknown; update?: unknown }).update).toBeUndefined();
}
Récap actionnable
| Symptôme | Faux ami | Vraie protection |
|---|---|---|
Composant qui .set() sur un signal exposé |
readonly sur la propriété |
asReadonly() sur le signal privé |
Signal publié sans .set() visible dans l'IDE |
Typage Signal<T> sur un WritableSignal |
asReadonly() renvoie un objet sans .set() runtime |
| Vue dérivée exposée en écriture | Getter qui renvoie le signal brut | computed() qui renvoie une primitive ou une copie |
Mutation en place d'un tableau/objet exposé (.items().push()) |
asReadonly() cru suffisant à lui seul |
Jamais de mutation en place dans les méthodes du service |
Régression : un dev réexpose un WritableSignal |
Convention de nommage | Test unitaire qui vérifie store.x.set === undefined |
Trois réflexes à graver :
- Tout
signal()de service est privé par défaut. La version publique passe parasReadonly()oucomputed(). Signal<T>etreadonlysont des indications au compilateur, pas des verrous. Le verrou runtime, ici, c'est l'absence de méthode.set/.updatesur l'objet exposé, pas une garantie contre la mutation de la valeur qu'il contient (tableaux et objets restent mutables en place, voir plus haut).- Un test unitaire d'une ligne rend le contrat non-régressable. Ajoute-le au moment où tu crées le service, pas trois refactos plus tard.
Ça ne rend pas ton architecture magiquement bonne. Ça ferme le vecteur .user.set() public, celui qui te force sinon à réauditer 15 composants pour trouver qui a écrit null dans ton user courant. Le signal privé reste techniquement joignable via (store as any).userSignal.set(...) : si tu veux verrouiller la référence privée elle-même, passe à un champ privé natif JavaScript (#userSignal, un "ES private field"), que le moteur rend inaccessible de l'extérieur même en cast any, contrairement à private TypeScript qui n'existe qu'à la compilation.