TypeScript le système de types - 04 - Immutabilite et readonly

Comment utiliser Readonly, ReadonlyArray, as const et Object.freeze pour empecher les mutations accidentelles en TypeScript.

04 - Immutabilite et readonly

Ce que tu vas apprendre

  • Comment readonly protégé les propriétés au niveau des types
  • La différence entre Readonly<T>, ReadonlyArray<T> et as const
  • Ce que Object.freeze fait (et ne fait pas) au runtime
  • Comment construire un type DeepReadonly pour les objets imbriques

Prerequisites

Avoir lu l'article sur les valeurs vs références.


Quand le state React disparaît

Un bug classique en React. Un dev junior sur un projet client avait ce code :

typescriptconst [users, setUsers] = useState<User[]>([])

function addUser(user: User) {
  users.push(user)
  setUsers(users)
}

Le tableau se remplissait en mémoire, mais l'interface ne se mettait jamais à jour. Le dev etait perdu : "mais j'appelle bien setUsers !".

Le problème : users.push(user) mute le tableau existant. React compare les références pour détecter les changements. Comme users pointe toujours vers le meme tableau, React considéré qu'il n'y a rien a re-rendre. Le fix :

typescriptfunction addUser(user: User) {
  setUsers([...users, user]) // nouveau tableau = nouvelle reference
}

Si le type du state avait ete readonly, TypeScript aurait refuse le .push() des le depart. L'erreur aurait ete détectée a la compilation, pas en debug devant un ecran qui ne bouge pas.

readonly sur les propriétés

Le mot-clé readonly empeche la reassignation d'une propriété apres l'initialisation :

typescriptinterface Config {
  readonly port: number
  readonly host: string
  debug: boolean
}

const config: Config = { port: 3000, host: "localhost", debug: false }

config.debug = true  // ✅ mutable
config.port = 8080   // ❌ Cannot assign to 'port' because it is a read-only property

C'est une protection au niveau du compilateur. Le JavaScript généré n'a aucune différence. Si tu contournes TypeScript (avec un cast, un any, ou du code JS pur), la mutation passera quand meme. Mais dans un projet TypeScript strict, readonly attrape les reassignations accidentelles.

Readonly : tout en lecture seule

Le type utilitaire Readonly<T> rend toutes les propriétés d'un objet readonly :

typescriptinterface User {
  name: string
  age: number
  email: string
}

type ImmutableUser = Readonly<User>
// equivalent a :
// {
//   readonly name: string
//   readonly age: number
//   readonly email: string
// }

C'est utile pour les paramètres de fonctions. Quand une fonction recoit un objet qu'elle ne doit pas modifier :

typescriptfunction formatUser(user: Readonly<User>): string {
  // user.name = "test" ❌ — le compilateur refuse
  return `${user.name} (${user.age})`
}

C'est le pattern que j'aurais du appliquer dans l'histoire de l'article précédent avec applyDiscount. Si le paramètre etait Readonly<Order>, le compilateur aurait refuse order.total = ....

ReadonlyArray et readonly T[]

Pour les tableaux, deux syntaxes equivalentes :

typescriptconst numbers: ReadonlyArray<number> = [1, 2, 3]
const names: readonly string[] = ["Alice", "Bob"]

numbers.push(4)    // ❌ Property 'push' does not exist on type 'readonly number[]'
numbers[0] = 99    // ❌ Index signature in type 'readonly number[]' only permits reading
numbers.sort()     // ❌ sort mute — interdit sur readonly

// Les methodes non-mutantes fonctionnent
const doubled = numbers.map(n => n * 2) // ✅ retourne un nouveau tableau
const filtered = numbers.filter(n => n > 1) // ✅
const first = numbers[0] // ✅ lecture

TypeScript retire push, pop, shift, unshift, splice, sort, reverse et l'écriture par index. Les méthodes qui retournent un nouveau tableau (map, filter, slice, concat) restent disponibles.

Je recommande readonly sur les paramètres de fonctions qui recoivent des tableaux :

typescriptfunction sum(values: readonly number[]): number {
  return values.reduce((acc, v) => acc + v, 0)
}

Ca documente l'intention (cette fonction ne mute pas le tableau) et le compilateur le vérifié.

as const : tout figer d'un coup

as const transforme une valeur en son type literal le plus precis, et marque tout comme readonly recursivement :

typescriptconst config = {
  port: 3000,
  host: "localhost",
  cors: {
    origins: ["http://localhost:3000"]
  }
} as const

// type:
// {
//   readonly port: 3000
//   readonly host: "localhost"
//   readonly cors: {
//     readonly origins: readonly ["http://localhost:3000"]
//   }
// }

config.port = 8080 // ❌
config.cors.origins.push("http://example.com") // ❌

as const est puissant parce qu'il combine trois effets :

  1. Pas de widening : 3000 reste 3000, pas number
  2. Toutes les propriétés deviennent readonly
  3. Les tableaux deviennent des tuples readonly

C'est ideal pour les configurations statiques, les constantes, les enums-like, et les tableaux de valeurs fixes. L'article sur les enums vs unions montre comment as const remplace les enums.

Object.freeze : la protection runtime

Object.freeze est la version runtime de readonly. Il empeche l'ajout, la suppression et la modification des propriétés d'un objet :

typescriptconst config = Object.freeze({
  port: 3000,
  host: "localhost"
})

config.port = 8080 // silencieusement ignore en mode normal, TypeError en strict mode

TypeScript reconnait Object.freeze et type le retour comme Readonly<T> :

typescriptconst config = Object.freeze({ port: 3000, host: "localhost" })
// type: Readonly<{ port: number; host: string }>

Le problème : Object.freeze est shallow. Exactement comme le spread.

typescriptconst config = Object.freeze({
  server: {
    port: 3000
  }
})

config.server.port = 8080 // ✅ pas d'erreur — server n'est pas frozen

Object.freeze gele les propriétés de premier niveau. Les objets imbriques restent mutables. Et le type Readonly<T> de TypeScript ne descend qu'un niveau non plus.

DeepReadonly : la protection profonde

Pour rendre un objet complètement immutable au niveau des types, il faut un type recursif :

typescripttype DeepReadonly<T> = T extends (infer U)[]
  ? readonly DeepReadonly<U>[]
  : T extends object
    ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
    : T

Ca descend dans chaque propriété, chaque élément de tableau, et applique readonly a tous les niveaux :

typescriptinterface AppConfig {
  server: {
    port: number
    cors: {
      origins: string[]
    }
  }
  database: {
    host: string
    credentials: {
      user: string
      password: string
    }
  }
}

type ImmutableConfig = DeepReadonly<AppConfig>

const config: ImmutableConfig = {
  server: { port: 3000, cors: { origins: ["http://localhost"] } },
  database: { host: "localhost", credentials: { user: "admin", password: "secret" } }
}

config.server.port = 8080 // ❌
config.server.cors.origins.push("http://example.com") // ❌
config.database.credentials.password = "new" // ❌

C'est un type avance (on en reparlera dans la sous-serie types avances), mais il est utile des maintenant pour les configurations et les objets de state complexes.

as const vs Readonly vs Object.freeze

Niveau Compilation Runtime Profondeur
readonly (propriété) Propriété Oui Non 1 niveau
Readonly<T> Objet Oui Non 1 niveau
as const Valeur Oui Non Profond
Object.freeze Objet Oui (Readonly) Oui (shallow) 1 niveau
DeepReadonly<T> Type Oui Non Profond

Ma recommandation : utilise as const pour les valeurs constantes définies dans le code. Utilise Readonly<T> et readonly dans les signatures de fonctions. Object.freeze seulement si tu as besoin d'une protection runtime (rare en pratique).


Résumé

  • readonly sur une propriété empeche sa reassignation au niveau du compilateur
  • Readonly<T> rend toutes les propriétés readonly mais seulement au premier niveau
  • ReadonlyArray<T> / readonly T[] retire les méthodes mutantes des tableaux
  • as const fige une valeur en type literal readonly profond
  • Object.freeze protégé au runtime mais seulement au premier niveau
  • DeepReadonly<T> est un type recursif pour une immutabilité profonde au niveau des types

Article précédent : 03 - Valeurs vs références

Article suivant : 05 - Egalite structurelle vs referentielle

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.