📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

~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 ton set() 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.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

4 champs optionnels = 16 états possibles, 4 valides : passe aux unions discriminées

26 juillet 2026

Ton type d'état a quatre `?`, donc seize combinaisons, dont la plupart n'ont aucun sens métier. Résultat : des `!` partout, un dernier `return` poubelle, et un ordre de `if` qui tient lieu de spécification. Comment passer d'un sac d'optionnels à une union discriminée, ce que le narrowing te rend, les 4 pièges qui cassent le discriminant, et un piège de narrowing que les templates Angular rendent quasi inévitable (vérifié au compilateur).

TypeScript Angular Signals Bonnes pratiques Architecture Templates

`enum` laisse du JavaScript derrière lui, et Node refuse déjà de l'exécuter

26 juillet 2026

Ton `enum` produit un objet dans ton bundle, refuse de passer dans un template Angular, et le jour où quelqu'un insère un état au milieu, tes commandes déjà persistées en `Sent` redeviennent `Pending`. Node 22 refuse purement et simplement de l'exécuter. Ce qu'il coûte vraiment, l'argument honnête en sa faveur (et pourquoi il tient moins qu'on croit), et les trois patterns de remplacement.

TypeScript Angular Bonnes pratiques Architecture Migration Bundle

Signal `equal` : pourquoi ton `set()` avec un objet ne re-render rien (et les 5 pièges qui te bouffent la journée)

17 juillet 2026

Tu appelles set() avec un nouvel objet, ton composant ne bouge pas. Ou pire : tu appelles set() avec la même valeur, et Angular re-render quand même. La fonction equal des signals est piégeuse dès que tu quittes les primitives. Tour des 5 pièges qui sabotent tes re-renders et les patterns pour reprendre la main sans casser les perfs.

Angular Signals Réactivité Bonnes pratiques Performance

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.