04 - Immutabilite et readonly
Ce que tu vas apprendre
- Comment
readonlyprotégé les propriétés au niveau des types - La différence entre
Readonly<T>,ReadonlyArray<T>etas const - Ce que
Object.freezefait (et ne fait pas) au runtime - Comment construire un type
DeepReadonlypour 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 :
- Pas de widening :
3000reste3000, pasnumber - Toutes les propriétés deviennent
readonly - 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é
readonlysur une propriété empeche sa reassignation au niveau du compilateurReadonly<T>rend toutes les propriétésreadonlymais seulement au premier niveauReadonlyArray<T>/readonly T[]retire les méthodes mutantes des tableauxas constfige une valeur en type literalreadonlyprofondObject.freezeprotégé au runtime mais seulement au premier niveauDeepReadonly<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