~8 min de lecture
readonly n'est pas un commentaire : rends la mutation impossible au lieu d'y survivre
Tu as déjà vécu la scène. Un composant ne se met pas à jour, tu remontes la piste, tu trouves un push() sur le tableau d'un signal. Tu remplaces par un spread, le re-render repart, tu passes à autre chose.
Le bug est parti. La cause est intacte. Le type autorise toujours exactement la même chose, et la prochaine mutation sera écrite par quelqu'un qui n'a pas vécu la scène.
C'est le sujet de cet article : arrêter de traiter l'immutabilité comme une discipline d'équipe, et la déplacer là où elle se vérifie toute seule.
Vérifié sur TypeScript 6.0.3 et Angular 22. Les conséquences d'une mutation sur la détection de changement ne sont pas rappelées ici : elles sont traitées dans Signal
equal: pourquoi tonset()avec un objet ne re-render rien. Ici, on cherche à rendre le problème inexprimable.
TL;DR
| Outil | Portée | Moment | Coût |
|---|---|---|---|
readonly sur une propriété |
1 niveau | compilation | zéro |
readonly T[] |
le tableau, pas ses éléments | compilation | zéro |
Readonly<T> |
1 seul niveau, c'est le piège | compilation | zéro |
as const |
profond (tous les niveaux), littéraux uniquement | compilation | zéro |
Object.freeze |
1 niveau | exécution | un appel, et un échec repoussé à l'exécution |
Règle : si une mutation est possible, elle finira par être écrite. Le seul endroit où l'interdire à l'échelle de tout le code, c'est le type.
readonly T[] : les mutateurs disparaissent de l'API
C'est le gain le plus immédiat, et le plus sous-utilisé. Déclare un tableau en lecture seule et les méthodes qui mutent en place n'existent tout simplement plus :
declare const list: readonly string[];
list.push('a'); // Property 'push' does not exist on type 'readonly string[]'
list.sort(); // idem
list.reverse(); // idem
Ce n'est pas une convention de nommage ni un commentaire d'intention. push, sort, reverse, splice, pop, shift, unshift sont absents du type, donc absents de l'autocomplétion. On ne peut pas les appeler par mégarde, parce qu'on ne les voit pas.
ReadonlyArray<T> désigne exactement le même type que readonly T[]. Prends la forme courte par défaut, avec une exception à connaître : elle n'est pas acceptée en position extends d'une interface, où il faut écrire interface Names extends ReadonlyArray<string> {}.
Le piège Angular : .sort() dans un computed()
Voilà le cas concret qui justifie tout l'article.
const labels = signal<string[]>(['Charlie', 'Alice', 'Bob']);
const sorted = computed(() => labels().sort());
Ça se lit comme une dérivation pure. Ça n'en est pas une, et pour deux raisons cumulées.
D'abord, .sort() trie en place. Il ne rend pas un nouveau tableau, il réordonne celui sur lequel on l'appelle, c'est-à-dire le tableau que ton signal détient. Vérifié à l'exécution :
source avant : ["Charlie","Alice","Bob"]
source apres : ["Alice","Bob","Charlie"]
meme reference : true
Ton computed(), censé être une lecture, vient de réécrire sa propre source.
Ensuite, il retourne la même référence, avec la conséquence sur la détection de changement décrite dans l'article renvoyé plus haut.
Ce qui nous intéresse ici, c'est qu'aucune review ne va attraper ça de façon fiable. labels().sort() a l'air correct. La seule défense qui tienne dans le temps, c'est que la ligne ne compile pas.
Le correctif est dans le type du signal
const labels = signal<readonly string[]>(['Charlie', 'Alice', 'Bob']);
const sorted = computed(() => labels().sort());
// Property 'sort' does not exist on type 'readonly string[]'
Le bug est devenu une erreur de compilation, avant même d'être écrit en entier : l'autocomplétion ne propose plus sort après labels().
La forme correcte copie avant de trier :
const sorted = computed<readonly string[]>(() => [...labels()].sort());
Et toSorted() ?
Tu croiseras toSorted() et toReversed(), qui rendent une copie - triée pour l'un, inversée pour l'autre - sans toucher à l'original. Séduisant, mais vérifie ta configuration avant de les dégainer.
Ce sont des ajouts ES2023. Un projet Angular généré aujourd'hui pose "target": "ES2022" et aucune clé lib : TypeScript en déduit alors la bibliothèque es2022, qui ne les contient pas. Résultat :
error TS2550: Property 'toSorted' does not exist on type 'string[]'. Do you need to change your target library? Try changing the 'lib' compiler option to 'es2023' or later.
Note bien la formulation du diagnostic : il te dit de changer lib, alors que dans un tsconfig.json Angular fraîchement généré cette clé n'existe pas. Il faut l'ajouter, ou monter target.
Les deux options sont défendables, et ce n'est pas une décision individuelle : monter la cible engage le support navigateur de toute l'application. [...arr].sort() marche sans rien changer. Ce qui n'est pas défendable, c'est de découvrir la contrainte le jour de la montée de version, quand elle bloque une PR pressée.
Readonly<T> te trahit : il est superficiel
Celui-là mérite un avertissement, parce que son nom promet plus qu'il ne tient.
type User = { name: string; tags: string[]; profile: { city: string } };
declare const u: Readonly<User>;
u.name = 'x'; // erreur, bien
u.profile.city = 'Paris'; // COMPILE
u.tags.push('nope'); // COMPILE
Readonly<T> ne protège que le premier niveau. Les objets et tableaux imbriqués restent parfaitement mutables. Un état de composant un peu profond annoté Readonly<State> te donne une impression de sécurité sur exactement une couche.
DeepReadonly : le vrai piège n'est pas celui qu'on croit
Le réflexe suivant est de chercher une version profonde. On en trouve partout des variantes de cette forme :
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends (infer U)[]
? readonly DeepReadonly<U>[]
: T[K] extends object
? DeepReadonly<T[K]>
: T[K];
};
Elle tient sa promesse sur des données pures, à n'importe quelle profondeur, tableaux compris. On lit souvent qu'il faut s'en méfier pour son coût de compilation. Mesuré sur un graphe à 41 niveaux imbriqués, c'est faux : environ 5 % d'instanciations en plus, et un temps de vérification noyé dans le bruit, parfois plus rapide que sans. Le coût ne devient perceptible qu'à des profondeurs que ton état de composant n'atteindra jamais.
Le vrai défaut est ailleurs, et il est bien plus vicieux : ce type détruit toute propriété qui porte des méthodes.
type Order = { id: string; createdAt: Date };
declare const o: DeepReadonly<Order>;
o.createdAt.getTime();
// error TS2349: This expression is not callable.
// Type 'DeepReadonly<Date>' has no call signatures.
Une Date dans un état, c'est-à-dire le cas le plus banal du monde, et le type devient inutilisable au premier niveau. Le mapping traverse Date et transforme ses méthodes en objets mappés, qui ne s'appellent plus.
Le correctif tient dans une garde placée en première branche, pour que les fonctions ressortent intactes :
type DeepReadonly<T> = T extends (...args: never[]) => unknown
? T
: T extends readonly (infer U)[]
? readonly DeepReadonly<U>[]
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
Avec ça, o.createdAt.getTime() compile et o.id = 'x' reste refusé, à tous les niveaux.
Une limite subsiste, à connaître : Map et Set ne sont pas protégés. Leurs mutateurs étant des méthodes, la garde les préserve, donc state.cache.set('a', 1) compile toujours. Pour ceux-là, TypeScript fournit ReadonlyMap<K, V> et ReadonlySet<T>, à écrire directement dans le type.
Le bon réflexe : readonly à la source
Tout ce qui précède décrit des enveloppes appliquées après coup. L'usage qui rapporte vraiment est ailleurs : déclarer les propriétés readonly dans le type du domaine, une fois, à l'endroit où il est défini.
type Item = {
readonly id: string;
readonly label: string;
};
type State = {
readonly items: readonly Item[];
readonly selectedId: string | null;
};
Une fois le type écrit comme ça, il n'y a plus rien à envelopper. La contrainte voyage avec le type, partout où il est utilisé, sans que personne ait à se souvenir d'appliquer un utilitaire.
C'est aussi ce qui rend la modélisation par union discriminée confortable : quand chaque état est un membre distinct et que ses champs sont readonly, la question "faut-il patcher ou reconstruire ?" ne se pose plus, elle est tranchée par le type.
as const : profond, mais réservé aux littéraux
const cfg = { a: 1, nested: { b: 2 }, list: [1, 2] } as const;
cfg.nested.b = 3; // erreur : profond, lui
cfg.list.push(3); // erreur aussi
as const fige tous les niveaux, contrairement à Readonly<T>. C'est l'outil des constantes de configuration, des tables de correspondance et des tuples ([string, number]).
Sa limite est structurelle : il s'applique à une expression littérale, écrite sur place. Il ne fige pas une valeur qui arrive d'une API, d'un formulaire ou d'un store. Pour ces valeurs-là, tu es sur le terrain de readonly dans le type.
C'est aussi le socle du remplacement des enum, détaillé dans l'article sur les unions de littéraux.
Object.freeze : le seul de cette liste à exister à l'exécution
Tous les outils précédents disparaissent à la compilation. Object.freeze est le seul de cette liste à survivre au runtime - ce n'est pas le seul mécanisme d'exécution qui existe, Object.seal, Object.defineProperty et les getters sans setter en sont d'autres, mais c'est celui qu'on croise. Deux choses à savoir avant de le semer partout.
Il est superficiel, lui aussi :
const cfg = Object.freeze({ name: 'a', nested: { city: 'Lyon' } });
cfg.nested.city = 'Paris';
// nested mute malgre freeze : Paris
Et au premier niveau, il lève une exception :
TypeError: Cannot assign to read only property 'name' of object '#<Object>'
C'est bien une exception, pas un échec silencieux, parce que les modules ES sont en mode strict JavaScript d'office - le 'use strict' du langage, rien à voir avec le strict de ton tsconfig.json - et que tout code Angular est livré en modules ES. En dehors de ce mode, la même affectation échouerait sans rien dire.
Quand est-ce que ça vaut le coup ? Rarement, et pour une raison précise : quand la valeur franchit une frontière que les types ne couvrent pas. Un objet partagé avec du code non typé, un token d'injection exposé à du code tiers, une constante qui traverse une API publique de bibliothèque. Partout ailleurs, readonly t'a déjà protégé, gratuitement et sans jamais lever d'exception à l'exécution.
Côté Angular : un WritableSignal public, c'est un set() offert à tout le monde
Dernier point, souvent oublié dans les services à signals :
@Injectable({ providedIn: 'root' })
export class CartStore {
private readonly itemsInternal = signal<readonly Item[]>([]);
readonly items = this.itemsInternal.asReadonly();
add(item: Item): void {
this.itemsInternal.update((current) => [...current, item]);
}
}
asReadonly() rend un Signal<T> : les consommateurs lisent, seul le store écrit. En le combinant au readonly Item[] à l'intérieur, tu fermes les deux portes - on ne peut ni remplacer la valeur du signal depuis l'extérieur, ni muter le tableau qu'il contient.
Note que le readonly de readonly items = ... et celui de readonly Item[] ne font pas le même travail : le premier empêche de réassigner la propriété du service, le second empêche de modifier le contenu du tableau. Il en faut deux parce qu'il y a deux choses différentes à protéger.
En résumé
| Ce que tu protèges | Ce que tu écris |
|---|---|
| Une propriété de modèle | readonly id: string dans le type du domaine |
| Une collection | readonly Item[] |
| Un dictionnaire | ReadonlyMap<K, V> / ReadonlySet<T> |
| Une constante littérale | as const |
| Un signal exposé par un service | asReadonly() |
| Une valeur qui sort de ton typage | Object.freeze, en connaissant sa superficialité |
Ce qu'il ne faut pas retenir : "il faut mettre Readonly<T> partout". Il est superficiel, et l'appliquer mécaniquement donne surtout l'illusion d'avoir traité le sujet.
Ce qu'il faut retenir tient à un déplacement : l'immutabilité se décide une fois, à la déclaration du type de domaine, et non à chaque endroit qui manipule la valeur. Un Item dont les champs sont readonly et une collection déclarée readonly Item[] protègent tous les consommateurs, présents et à venir, sans que personne ait à s'en souvenir.
Le spread que tu as écrit pour réparer ton re-render était le bon geste. C'est la mutation qui n'aurait jamais dû être une option.