2. Variance et assignabilité
Sections
Réglages de lecture
Aucune voix pour cette langue dans ce navigateur.
Plus de voix
Code et termes techniques en anglais
Désactivé : la voix de la page lit tout, sans pause entre deux voix.
Aucune voix pour cette langue dans ce navigateur.
Plus de voix
D’autres voix : Microsoft Edge propose des voix naturelles en ligne, et Windows (Paramètres › Heure et langue › Voix), macOS et Android en installent d’autres.
Code : les fichiers examples/l02_* et errors/l02_assignability.ts, et les côtés C# et Java dans compare/ (l02_variance.cs, L02TargetTyping.java) et compare_fail/ (l02_variant_setter.cs, l02_return_inference.cs, L02Wildcard.java).
La leçon 4 du cours de base a montré les règles : les tableaux sont covariants et non vérifiés, les propriétés de type fonction sont vérifiées de façon contravariante sous strictFunctionTypes, les paramètres de méthode restent bivariants, et les annotations in et out sont facultatives. Cette leçon les mesure, trouve où elles laissent passer une mauvaise valeur, puis regarde l’autre moitié de « est-ce assignable » : comment tsc décide d’abord du type d’une valeur, et comment satisfies, const et NoInfer changent cette décision.
Mesurer la variance
Section intitulée « Mesurer la variance »La variance s’observe sans lire le vérificateur : construis le même type générique pour un sous-type et un supertype, et teste l’assignabilité dans les deux sens.
import type { Equal, Expect } from './type-tests.ts';import { attempt, show } from './show.ts';
interface Instrument { name: string;}interface Guitar extends Instrument { strings: number; tune(): string;}
// Variance mesurée, testée sur l'assignabilité dans les deux senstype Variance<Sub, Super> = [Sub] extends [Super] ? ([Super] extends [Sub] ? 'bivariant' : 'covariant') : [Super] extends [Sub] ? 'contravariant' : 'invariant';
interface Producer<T> { get: () => T;}interface Consumer<T> { set: (value: T) => void;}interface Both<T> { get: () => T; set: (value: T) => void;}interface WithMethod<T> { set(value: T): void;}interface Callback<T> { subscribe: (listener: (value: T) => void) => void;}interface Slot<T> { value: T;}interface ReadonlySlot<T> { readonly value: T;}
type _1 = Expect<Equal<Variance<Producer<Guitar>, Producer<Instrument>>, 'covariant'>>;type _2 = Expect<Equal<Variance<Consumer<Guitar>, Consumer<Instrument>>, 'contravariant'>>;type _3 = Expect<Equal<Variance<Both<Guitar>, Both<Instrument>>, 'invariant'>>;// Un paramètre de méthode est bivariant, même sous strictFunctionTypestype _4 = Expect<Equal<Variance<WithMethod<Guitar>, WithMethod<Instrument>>, 'bivariant'>>;// Un paramètre d'un paramètre redevient covariant : deux positions contravariantes s'annulenttype _5 = Expect<Equal<Variance<Callback<Guitar>, Callback<Instrument>>, 'covariant'>>;// Une propriété mutable est lue et écrite, et quand même mesurée covariantetype _6 = Expect<Equal<Variance<Slot<Guitar>, Slot<Instrument>>, 'covariant'>>;type _7 = Expect<Equal<Variance<ReadonlySlot<Guitar>, ReadonlySlot<Instrument>>, 'covariant'>>;
// La propriété mutable covariante n'est pas sûre : un piano entre dans l'emplacement de la guitare par un aliasconst guitar: Guitar = { name: 'guitar', strings: 6, tune: () => 'EADGBE' };const guitarSlot: Slot<Guitar> = { value: guitar };const instrumentSlot: Slot<Instrument> = guitarSlot;instrumentSlot.value = { name: 'piano' };attempt('guitarSlot.value.tune()', () => guitarSlot.value.tune());
// in out déclare l'invariance que la structure ne montre pas ; le même alias ne compile plusinterface SafeSlot<in out T> { value: T;}type _8 = Expect<Equal<Variance<SafeSlot<Guitar>, SafeSlot<Instrument>>, 'invariant'>>;const safeGuitarSlot: SafeSlot<Guitar> = { value: guitar };// @ts-expect-error: SafeSlot<Guitar> n'est pas un SafeSlot<Instrument>const safeInstrumentSlot: SafeSlot<Instrument> = safeGuitarSlot;show('safeGuitarSlot.value.tune()', safeGuitarSlot.value.tune());
// Le method bivariance hack de @types/react : un type fonction qui reste bivariant sous strictFunctionTypestype EventHandler<E> = { bivarianceHack(event: E): void }['bivarianceHack'];interface ClickEvent { x: number;}interface DoubleClickEvent extends ClickEvent { count: 2;}type _9 = Expect<Equal<Variance<EventHandler<DoubleClickEvent>, EventHandler<ClickEvent>>, 'bivariant'>>;const onDoubleClick: EventHandler<DoubleClickEvent> = (event) => console.log(`double click ${event.count}`);const onClick: EventHandler<ClickEvent> = onDoubleClick; // accepté : le hack laisse passer un gestionnaire plus étroitattempt('onClick({ x: 3 })', () => onClick({ x: 3 }));guitarSlot.value.tune() TypeError: guitarSlot.value.tune is not a functionsafeGuitarSlot.value.tune() 'EADGBE'double click undefinedonClick({ x: 3 }) undefinedLes tests de types dressent un tableau de ce que mesure tsc 7.0.2, pour Guitar extends Instrument :
Membre qui utilise T |
Mesure | Sûr ? |
|---|---|---|
get: () => T |
covariant | oui |
set: (value: T) => void |
contravariant | oui |
| les deux | invariant | oui |
set(value: T): void, une méthode |
bivariant | non |
subscribe: (listener: (value: T) => void) => void |
covariant | oui |
value: T, une propriété mutable |
covariant | non |
readonly value: T |
covariant | oui |
Deux lignes ne sont pas sûres, et c’est voulu. Un paramètre de méthode est bivariant, comme l’a montré le cours de base. Et une propriété mutable est mesurée covariante, bien qu’on puisse l’écrire : Slot<Guitar> est accepté comme Slot<Instrument>, l’alias y écrit un piano, et guitarSlot.value.tune() lève une exception. Le handbook sur la compatibilité des types le dit clairement : « le système de types de TypeScript autorise certaines opérations dont on ne peut pas savoir à la compilation qu’elles sont sûres », et les propriétés sont comparées selon leur type en lecture. C# refuse la déclaration équivalente, parce qu’un setter consomme T :
> dotnet run l02_variant_setter.cscompare_fail/l02_variant_setter.cs(7,5): error CS1961: Invalid variance: The type parameter 'T' must be invariantly valid on 'ISlot<T>.Value'. 'T' is covariant.
The build failed. Fix the build errors and run again.En C#, out T n’est autorisé que sur une propriété sans setter, comme IReadOnlySlot<out T> dans le programme de comparaison, et une List<Guitar> se convertit en IReadOnlyList<Instrument> mais pas en IList<Instrument> :
// C# déclare la variance sur les interfaces, et la vérifie par rapport à chaque membreList<Guitar> guitars = [new("guitar", 6)];IReadOnlyList<Instrument> instruments = guitars; // IReadOnlyList<out T> : covariante, et en lecture seuleIReadOnlySlot<Instrument> slot = new Slot<Guitar>(guitars[0]);Console.WriteLine($"{instruments[0].Name}, {slot.Value.Name}");
// L'inférence n'utilise que les arguments : un paramètre de type qui n'apparaît que dans le type de retour doit être écritList<string> chords = EmptyList<string>();Console.WriteLine(chords.Count);
static List<T> EmptyList<T>() => [];
record Instrument(string Name);record Guitar(string Name, int Strings) : Instrument(Name);
interface IReadOnlySlot<out T>{ T Value { get; }}
class Slot<T>(T value) : IReadOnlySlot<T>{ public T Value { get; set; } = value;}> dotnet run l02_variance.csguitar, guitar0in out T rétablit l’invariance. Une annotation ne peut pas changer la façon dont les types sont comparés structurellement, mais elle remplace la variance mesurée quand tsc compare deux instanciations du même type générique. SafeSlot<in out T> est invariant, et l’alias ne compile plus. Annote ainsi les conteneurs mutables quand le trou de typage compte. L’annotation n’est vérifiée par rapport à la structure que dans le sens qu’elle permet : in T sur un type qui lit T est refusé avec TS2636, tandis que in out T, qui ne permet rien, est accepté sur n’importe quel type.
Deux positions contravariantes en font une covariante. subscribe prend un écouteur, qui prend un T : un abonnement aux guitares peut servir d’abonnement aux instruments, parce que chaque écouteur écrit pour des instruments accepte des guitares. Le même raisonnement explique l’intersection qu’infer a produite dans la leçon 1 pour deux paramètres.
Le bivariance hack. @types/react déclare ses gestionnaires d’événements comme { bivarianceHack(event: E): void }["bivarianceHack"] : le type d’une méthode, extrait par accès indexé, est un type fonction qui garde la bivariance de la méthode. EventHandler<DoubleClickEvent> est accepté là où un EventHandler<ClickEvent> est attendu, et le gestionnaire lit un count qui n’existe pas. Les types de React le font exprès, pour qu’un gestionnaire écrit pour un événement précis puisse être passé à une prop typée avec un événement plus général ; le prix est le undefined de la sortie.
Où la mesure sert
Section intitulée « Où la mesure sert »tsc ne compare pas Slot<Guitar> et Slot<Instrument> membre par membre à chaque fois. Pour une interface, une classe ou un alias de type générique, getVariancesWorker mesure une seule fois la variance de chaque paramètre de type : il instancie le type avec deux types marqueurs, l’un sous-type de l’autre, et teste l’assignabilité dans les deux sens. Un troisième marqueur, sans rapport avec les deux autres, distingue un paramètre bivariant d’un paramètre inutilisé. Le résultat est mis en cache, et les comparaisons suivantes de deux instanciations ne comparent que leurs arguments de type. Quand la comparaison des marqueurs est passée par des constructions que la mesure ne sait pas représenter, comme un type conditionnel, le résultat est marqué non mesurable ou non fiable, et le vérificateur se rabat sur une comparaison structurelle.
Une annotation évite la mesure : dans la même fonction, out donne covariant, in contravariant et in out invariant, sans rien instancier. C’est l’usage pour lequel les annotations de variance de la 4.7 ont été conçues : sur un très gros type, une annotation économise la mesure, et sur un type dont la mesure n’est pas fiable, elle donne la réponse voulue. Le handbook recommande de ne les écrire que lorsqu’elles correspondent à la structure, et surtout après profilage.
| C# | Java | TypeScript | |
|---|---|---|---|
| Où la variance est déclarée | interfaces et délégués, in/out |
au point d’utilisation, ? extends/? super |
nulle part : mesurée ; in/out facultatifs |
| Une propriété modifiable | invariante, out refusé (CS1961) |
— | covariante, non sûre ; in out pour corriger |
| Paramètres des types fonction | contravariants (délégués, in T) |
— | contravariants pour les propriétés, bivariants pour les méthodes |
| Vérifiée | entièrement | entièrement, avec la conversion par capture | partiellement |
Java place la variance sur la variable, pas sur le type. Une List<? extends Object> peut être lue, et on ne peut rien y ajouter, ce qui est la forme au point d’utilisation de out T :
> javac L02Wildcard.javaL02Wildcard.java:12: error: incompatible types: Instrument cannot be converted to CAP#1 objects.add(new Instrument("piano")); ^ where CAP#1 is a fresh type-variable: CAP#1 extends Object from capture of ? extends ObjectNote: Some messages have been simplified; recompile with -Xdiags:verbose to get full output1 errorsatisfies, annotations et assertions
Section intitulée « satisfies, annotations et assertions »Quatre façons de dire « cet objet est un Record<GovernanceHealthStatus, HexColor> » donnent quatre types différents :
import type { Equal, Expect } from './type-tests.ts';import { show } from './show.ts';
type GovernanceHealthStatus = 'error' | 'warning' | 'healthy' | 'unknown' | 'contradictory';type HexColor = `#${string}`;
// 1. Une annotation : l'objet est vérifié, et son type devient l'annotation, donc les couleurs littérales sont perduesconst annotated: Record<GovernanceHealthStatus, HexColor> = { error: '#FF4444', warning: '#FFB300', healthy: '#33CC66', unknown: '#888888', contradictory: '#FF44FF',};type _1 = Expect<Equal<(typeof annotated)['error'], HexColor>>;
// 2. satisfies : la même vérification, et le type reste celui inféré à partir de l'objet. Les littéraux sont gardés ici// parce que le type contextuel, un template literal type, contient des types littéraux ; face à string, ils s'élargissentconst satisfying = { error: '#FF4444', warning: '#FFB300', healthy: '#33CC66', unknown: '#888888', contradictory: '#FF44FF',} satisfies Record<GovernanceHealthStatus, HexColor>;type _2 = Expect<Equal<(typeof satisfying)['error'], '#FF4444'>>;const widened = { error: '#FF4444' } satisfies Record<'error', string>;type _2b = Expect<Equal<(typeof widened)['error'], string>>;
// 3. as const satisfies : des valeurs littérales quel que soit le type contextuel, des propriétés readonly, et la vérificationconst colors = { error: '#FF4444', warning: '#FFB300', healthy: '#33CC66', unknown: '#888888', contradictory: '#FF44FF',} as const satisfies Record<GovernanceHealthStatus, HexColor>;type _3 = Expect<Equal<(typeof colors)['error'], '#FF4444'>>;type _3b = Expect<Equal<typeof colors, { readonly error: '#FF4444'; readonly warning: '#FFB300'; readonly healthy: '#33CC66'; readonly unknown: '#888888'; readonly contradictory: '#FF44FF' }>>;
// 4. as : une assertion, qui vérifie seulement qu'un type est comparable à l'autre ; un statut manquant passeconst partial = { error: '#FF4444', healthy: '#33CC66' };const asserted = partial as Record<GovernanceHealthStatus, HexColor>;show('asserted.warning', asserted.warning);
// satisfies fournit le type contextuel : le paramètre de chaque fonction est typé sans annotationinterface Formatters { [status: string]: (score: number) => string;}const formatters = { healthy: (score) => `healthy (${score.toFixed(2)})`, warning: (score) => `watch (${Math.round(score * 100)}%)`,} satisfies Formatters;show('formatters.healthy(0.93)', formatters.healthy(0.93));// Le type inféré garde exactement les deux clés : formatters.error serait une erreur de compilation, pas undefined à l'exécutiontype _4 = Expect<Equal<keyof typeof formatters, 'healthy' | 'warning'>>;
// VoxtralTTS.ts de GA vérifie de la même façon le corps d'une requête, avant que JSON.stringify n'efface toutconst body = JSON.stringify({ model: 'voxtral-mini-tts-2603', input: 'The voicing index is fresh.', voice_id: 'demerzel',} satisfies { model: string; input: string; voice_id: string });show('body', body);asserted.warning undefinedformatters.healthy(0.93) 'healthy (0.93)'body '{"model":"voxtral-mini-tts-2603","input":"The voicing index is fresh.","voice_id":"demerzel"}'- Une annotation vérifie l’objet et remplace son type par l’annotation.
annotated.errorest unHexColor: le fait qu’il vaille'#FF4444'a disparu. satisfies(4.9) vérifie l’objet par rapport au type, et garde le type inféré à partir de l’objet. Que les littéraux survivent dépend du type contextuel : face à`#${string}`, un template literal type, le vérificateur garde'#FF4444'; face àstring, il élargit àstring, comme le montrewidened. Je m’attendais à ce que le littéral soit élargi dans les deux cas, et le test de types_2m’a corrigé.as const satisfiesgarde les littéraux quel que soit le type contextuel, rend chaque propriétéreadonly, et vérifie quand même qu’aucun statut ne manque et que chaque valeur est une couleur hexadécimale.- Une assertion,
as, ne vérifie presque rien : un type doit être comparable à l’autre.partial, avec deux statuts, est comparable au record, puisque le record est assignable à{ error: string; healthy: string }, etasserted.warningvautundefinedà l’exécution. Avec un littéral objet écrit directement aprèsas,tsc7.0.2 signale bien les propriétés manquantes (TS2352), parce que les types du littéral sont alors les littéraux eux-mêmes, auxquels le record n’est pas assignable.
satisfies fournit aussi un type contextuel, ce qui explique que les fonctions de formatters n’aient besoin d’aucune annotation de paramètre, et il garde les clés inférées : formatters.error est une erreur de compilation, là où une annotation avec une signature d’index l’aurait typé comme une fonction et aurait renvoyé undefined.
GA utilise satisfies une fois dans sa bibliothèque de composants, dans VoxtralTTS.ts, sur le corps d’une requête juste avant JSON.stringify, qui efface tous les types. C’est le bon endroit : l’objet garde son type inféré, et un voice_id mal orthographié serait détecté. Le reste de la bibliothèque utilise des annotations, comme HEALTH_STATUS_COLORS: Record<GovernanceHealthStatus, string> dans types.ts, ce qui suffit là où seule la vérification compte.
Contrôler l’inférence
Section intitulée « Contrôler l’inférence »import type { Equal, Expect } from './type-tests.ts';import { show } from './show.ts';
// Sans const, un argument tableau est inféré comme string[] : les notes sont oubliéesfunction tuning<T extends readonly string[]>(notes: T): T { return notes;}const plain = tuning(['D', 'A', 'D', 'G', 'A', 'D']);type _1 = Expect<Equal<typeof plain, string[]>>;
// Un paramètre de type const (5.0) infère comme si l'appelant avait écrit as constfunction constTuning<const T extends readonly string[]>(notes: T): T { return notes;}const dadgad = constTuning(['D', 'A', 'D', 'G', 'A', 'D']);type _2 = Expect<Equal<typeof dadgad, readonly ['D', 'A', 'D', 'G', 'A', 'D']>>;// Avec une contrainte mutable, le tuple est inféré mutable (depuis la 5.3 ; de la 5.0 à la 5.2, on retombait sur string[])function constMutable<const T extends string[]>(notes: T): T { return notes;}const fallback = constMutable(['D', 'A', 'D']);type _3 = Expect<Equal<typeof fallback, ['D', 'A', 'D']>>;
// Chaque argument est un site d'inférence : ici initial ajoute sa valeur à l'union inférée à partir de statesfunction machine<S extends string>(states: readonly S[], initial: S) { return { states, current: initial };}const loose = machine(['idle', 'listening', 'processing'], 'understood');type _4 = Expect<Equal<typeof loose.current, 'idle' | 'listening' | 'processing' | 'understood'>>;
// NoInfer (5.4) retire un argument de l'inférence : S vient de states seul, et initial est vérifié par rapport à luifunction strictMachine<S extends string>(states: readonly S[], initial: NoInfer<S>) { return { states, current: initial };}const strict = strictMachine(['idle', 'listening', 'processing', 'understood'], 'idle');type _5 = Expect<Equal<typeof strict.current, 'idle' | 'listening' | 'processing' | 'understood'>>;// @ts-expect-error: 'speaking' ne fait pas partie des étatsstrictMachine(['idle', 'listening'], 'speaking');
// Inférence à partir du type de retour : le type déclaré de la variable se propage dans l'appelfunction emptyList<T>(): T[] { return [];}const chords: string[] = emptyList(); // T est string, inféré à partir du contexte, comme le fait Java et pas C#type _6 = Expect<Equal<ReturnType<typeof emptyList<number>>, number[]>>;
show('dadgad', dadgad);show('loose.current', loose.current);show('chords.length', chords.length);dadgad [ 'D', 'A', 'D', 'G', 'A', 'D' ]loose.current 'understood'chords.length 0Les paramètres de type const. Sans eux, un littéral tableau passé à une fonction générique est inféré comme string[]. Un paramètre de type const (5.0) infère comme si l’appelant avait écrit as const, et dadgad garde ses six notes sous forme de tuple en lecture seule. Les notes de version de la 5.0 préviennent qu’une contrainte mutable, T extends string[], fait retomber l’inférence sur string[]. C’était vrai jusqu’à la 5.2 : j’ai exécuté le même fichier avec tsc 5.0.4, 5.2.2 et 5.3.3, et depuis la 5.3 le résultat est le tuple mutable ['D', 'A', 'D'], que donne aussi tsc 7.0.2.
Chaque argument est un site d’inférence. machine(states, initial) infère S à partir des deux paramètres. Le 'understood' passé comme initial n’est pas une erreur pour tsc : il devient un membre de plus de S, et la machine à états a maintenant un état qui n’est pas dans sa liste. NoInfer<T> (5.4) retire une position de l’inférence : S vient de states seul, et initial est vérifié par rapport à lui, donc 'speaking' est refusé.
L’inférence à partir du type de retour. const chords: string[] = emptyList() infère T comme string à partir du type déclaré de la variable. Java fait de même avec son typage par la cible, et C# non : un paramètre de type qui n’apparaît que dans le type de retour doit être écrit.
> java L02TargetTyping.java[Cmaj7]> dotnet run l02_return_inference.cscompare_fail/l02_return_inference.cs(2,23): error CS0411: The type arguments for method 'EmptyList<T>()' cannot be inferred from the usage. Try specifying the type arguments explicitly.compare_fail/l02_return_inference.cs(5,16): warning CS8321: The local function 'EmptyList' is declared but never used
The build failed. Fix the build errors and run again.L’avertissement CS8321 qui suit l’erreur est un effet de bord : une fois que l’appel échoue, le compilateur considère la fonction locale comme inutilisée.
Optionnel, undefined et null
Section intitulée « Optionnel, undefined et null »import type { Equal, Expect } from './type-tests.ts';import { show } from './show.ts';
// Le ViewerInfo de GA, tiré de DataLoader.ts : le record C# du serveur a string? DisplayName = null et string? AvatarUrl = nullinterface ViewerInfo { connectionId: string; color: string; displayName?: string; avatarUrl?: string | null;}
// Avec exactOptionalPropertyTypes, ? signifie que la propriété peut être absente, pas qu'elle peut contenir undefinedconst absent: ViewerInfo = { connectionId: 'a1', color: '#58a6ff' };const withNull: ViewerInfo = { connectionId: 'b2', color: '#3fb950', avatarUrl: null };show("'displayName' in absent", 'displayName' in absent);show('Object.keys(withNull)', Object.keys(withNull));
// Lire une propriété optionnelle donne quand même undefined quand elle est absentetype _1 = Expect<Equal<ViewerInfo['displayName'], string | undefined>>;// Partial<T> garde la règle : un patch peut omettre une propriété, et ne peut pas la mettre à undefinedfunction applyPatch(viewer: ViewerInfo, patch: Partial<ViewerInfo>): ViewerInfo { return { ...viewer, ...patch };}show('applyPatch(absent, …)', applyPatch(absent, { displayName: 'Ada' }));
// Pourquoi la règle compte : un spread copie une propriété propre qui contient undefined, et efface la valeurconst viewer: ViewerInfo = { connectionId: 'c3', color: '#d2a8ff', displayName: 'Hari' };const sloppyPatch = { displayName: undefined };show('{ ...viewer, ...sloppyPatch }', { ...viewer, ...sloppyPatch });
// Un type faible n'a que des propriétés optionnelles : tsc exige qu'un argument partage au moins l'une d'ellesinterface TuningOptions { capo?: number; dropD?: boolean;}function tune(options: TuningOptions): string { return `capo ${options.capo ?? 0}, drop D ${options.dropD ?? false}`;}const fromSettings = { capo: 2, theme: 'dark' };show('tune(fromSettings)', tune(fromSettings)); // partage capo : accepté, et theme est ignoré
// null et undefined sont des valeurs différentes, et JSON n'en a qu'uneshow('JSON.stringify(withNull)', JSON.stringify(withNull));show("JSON.stringify(sloppyPatch)", JSON.stringify(sloppyPatch));'displayName' in absent falseObject.keys(withNull) [ 'connectionId', 'color', 'avatarUrl' ]applyPatch(absent, …) { connectionId: 'a1', color: '#58a6ff', displayName: 'Ada' }{ ...viewer, ...sloppyPatch } { connectionId: 'c3', color: '#d2a8ff', displayName: undefined }tune(fromSettings) 'capo 2, drop D false'JSON.stringify(withNull) '{"connectionId":"b2","color":"#3fb950","avatarUrl":null}'JSON.stringify(sloppyPatch) '{}'Le tsconfig.json du cours active exactOptionalPropertyTypes, qui ne fait pas partie de strict. Avec cette option, displayName?: string signifie que la propriété peut être absente, pas qu’elle peut contenir undefined. La distinction existe à l’exécution : 'displayName' in absent vaut false, alors qu’un objet avec displayName: undefined possède la propriété. Elle compte pour les spreads, comme le montre la quatrième ligne de la sortie : un patch dont la propriété contient undefined efface la valeur sur laquelle il est étalé. JSON.stringify supprime complètement la propriété, et garde un null.
Le ViewerInfo de GA déclare displayName?: string et avatarUrl?: string | null. Le record du serveur, dans GovernanceHub.cs, est string? DisplayName = null, string? AvatarUrl = null, et la leçon 4 montre que SignalR envoie les deux sous forme de null. Le type de displayName est faux, et le code fonctionne quand même, parce que ForceRadiant.tsx le lit avec ?. et ??, qui traitent null comme undefined. Une vérification écrite viewer.displayName !== undefined, comme le suggère le type, laisserait passer null.
L’extrait ci-dessous rassemble les règles d’assignabilité de cette section et des précédentes. Il s’exécute deux fois : une fois avec les options du cours, et une fois avec --exactOptionalPropertyTypes false.
> npx tsc -p out/tsconfig.l02_assignability.json --prettyerrors/l02_assignability.ts:20:7 - error TS2375: Type '{ connectionId: string; displayName: undefined; }' is not assignable to type 'ViewerInfo' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties. Types of property 'displayName' are incompatible. Type 'undefined' is not assignable to type 'string'.
20 const explicit: ViewerInfo = { connectionId: 'a1', displayName: undefined }; ~~~~~~~~
errors/l02_assignability.ts:21:7 - error TS2375: Type '{ displayName: undefined; }' is not assignable to type 'Partial<ViewerInfo>' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties. Types of property 'displayName' are incompatible. Type 'undefined' is not assignable to type 'string'.
21 const patch: Partial<ViewerInfo> = { displayName: undefined }; ~~~~~
errors/l02_assignability.ts:25:7 - error TS2559: Type '{ theme: string; fontSize: number; }' has no properties in common with type 'TuningOptions'.
25 const options: TuningOptions = theme; ~~~~~~~
errors/l02_assignability.ts:30:16 - error TS2352: Conversion of type 'Guitar' to type 'string[]' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. Type 'Guitar' is missing the following properties from type 'string[]': length, pop, push, concat, and 35 more.
30 const tuning = guitar as string[]; ~~~~~~~~~~~~~~~~~~
errors/l02_assignability.ts:34:7 - error TS2322: Type 'SafeSlot<Guitar>' is not assignable to type 'SafeSlot<{ name: string; }>'. Property 'strings' is missing in type '{ name: string; }' but required in type 'Guitar'.
34 const namedSlot: SafeSlot<{ name: string }> = guitarSlot; ~~~~~~~~~
Found 5 errors in the same file, starting at: errors/l02_assignability.ts:20> npx tsc -p out/tsconfig.l02_assignability.json --pretty --exactOptionalPropertyTypes falseerrors/l02_assignability.ts:25:7 - error TS2559: Type '{ theme: string; fontSize: number; }' has no properties in common with type 'TuningOptions'.
25 const options: TuningOptions = theme; ~~~~~~~
errors/l02_assignability.ts:30:16 - error TS2352: Conversion of type 'Guitar' to type 'string[]' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. Type 'Guitar' is missing the following properties from type 'string[]': length, pop, push, concat, and 35 more.
30 const tuning = guitar as string[]; ~~~~~~~~~~~~~~~~~~
errors/l02_assignability.ts:34:7 - error TS2322: Type 'SafeSlot<Guitar>' is not assignable to type 'SafeSlot<{ name: string; }>'. Property 'strings' is missing in type '{ name: string; }' but required in type 'Guitar'.
34 const namedSlot: SafeSlot<{ name: string }> = guitarSlot; ~~~~~~~~~
Found 3 errors in the same file, starting at: errors/l02_assignability.ts:25TS2375: avecexactOptionalPropertyTypes,undefinedn’est pas une valeur d’une propriété optionnelle, dans un objet comme dans unPartial. La seconde exécution accepte les deux lignes.TS2559: un type faible, dont toutes les propriétés sont optionnelles, doit partager au moins une propriété avec la valeur qu’on lui assigne.{ theme, fontSize }n’a rien en commun avecTuningOptions, et il est refusé, bien qu’il soit structurellement assignable ;fromSettings, dans l’exemple, partagecapo, et il est accepté. La règle date de TypeScript 2.4.TS2352: une assertion entre des types qui ne sont pas comparables. Passer parunknownla fait taire, c’est pourquoias unknown asest le motif à rechercher dans une base de code : la bibliothèque de composants de GA en compte 34.TS2322surSafeSlot: l’annotationin outde la première section, et le message montre la vérification dans l’autre sens, de{ name: string }versGuitar.
À retenir
Section intitulée « À retenir »tscmesure la variance à partir de la structure ; les propriétés mutables sont mesurées covariantes et les paramètres de méthode bivariants, deux cas non sûrs.in out Trend un type invariant.- Deux positions contravariantes s’annulent ; le bivariance hack de
@types/reactgarde exprès un type fonction bivariant. - Une annotation remplace le type inféré,
satisfiesle vérifie et le garde,as const satisfiesgarde en plus les littéraux, etasne vérifie que la comparabilité. - Les paramètres de type
constinfèrent des tuples littéraux ;NoInferretire un argument de l’inférence ; un type de retour attendu guide aussi l’inférence, comme en Java et contrairement à C#. exactOptionalPropertyTypesdistingue une propriété absente d’une propriété qui contientundefined, etnullest un troisième cas, que JSON conserve.
Exercices
Section intitulée « Exercices »- Une petite bibliothèque de signaux déclare
interface Signal<T> { get(): T; set(value: T): void; subscribe(listener: (value: T) => void): () => void }. Prédis la variance que mesuretsc, vérifie-la avec un test de types, et écris deux versions invariantes.
Solution
import type { Equal, Expect } from '../examples/type-tests.ts';
type Variance<Sub, Super> = [Sub] extends [Super] ? ([Super] extends [Sub] ? 'bivariant' : 'covariant') : [Super] extends [Sub] ? 'contravariant' : 'invariant';
interface Instrument { name: string;}interface Guitar extends Instrument { tune(): string;}
// Le signal tel qu'il a été écrit : set est une méthode, donc son paramètre est bivariant, et T est mesuré covariantinterface Signal<T> { get(): T; set(value: T): void; subscribe(listener: (value: T) => void): () => void;}type _1 = Expect<Equal<Variance<Signal<Guitar>, Signal<Instrument>>, 'covariant'>>;
// Les propriétés de type fonction sont vérifiées sous strictFunctionTypes : get rend T covariant, set contravariantinterface CheckedSignal<T> { get: () => T; set: (value: T) => void; subscribe: (listener: (value: T) => void) => () => void;}type _2 = Expect<Equal<Variance<CheckedSignal<Guitar>, CheckedSignal<Instrument>>, 'invariant'>>;
// Ou garde les méthodes, et dis ce qu'elles signifientinterface AnnotatedSignal<in out T> { get(): T; set(value: T): void; subscribe(listener: (value: T) => void): () => void;}type _3 = Expect<Equal<Variance<AnnotatedSignal<Guitar>, AnnotatedSignal<Instrument>>, 'invariant'>>;
function signal<T>(initial: T): CheckedSignal<T> { let value = initial; const listeners = new Set<(value: T) => void>(); return { get: () => value, set: (next) => { value = next; for (const listener of listeners) listener(next); }, subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); }, };}
const guitar = signal<Guitar>({ name: 'guitar', tune: () => 'EADGBE' });guitar.subscribe((g) => console.log(`tuned to ${g.tune()}`));guitar.set({ name: 'baritone', tune: () => 'BEADF#B' });// @ts-expect-error: un CheckedSignal<Guitar> n'est pas un CheckedSignal<Instrument>, qui pourrait recevoir un pianoconst instruments: CheckedSignal<Instrument> = guitar;console.log(guitar.get().name, typeof instruments);tuned to BEADF#Bbaritone objectAvec des méthodes, set ne compte pas comme contravariant, donc get et subscribe rendent tout le signal covariant, et un Signal<Guitar> pourrait être rangé dans une variable Signal<Instrument> et recevoir un piano. Des propriétés de type fonction font s’appliquer strictFunctionTypes, et le signal devient invariant ; in out T obtient le même résultat en gardant la syntaxe des méthodes. L’implémentation utilise la version à propriétés, et la ligne @ts-expect-error est le test qui vérifie que l’alias dangereux est refusé.
- Écris
defineStatusColors(colors), une fonction qui fait ce que faitas const satisfies Record<GovernanceHealthStatus, HexColor>: des couleurs littérales dans le résultat, une erreur de compilation pour un statut manquant ou une couleur qui n’est pas hexadécimale.
Solution
solutions/l02_ex2_define_colors.ts :
import type { Equal, Expect } from '../examples/type-tests.ts';
type GovernanceHealthStatus = 'error' | 'warning' | 'healthy' | 'unknown' | 'contradictory';type HexColor = `#${string}`;
// Un paramètre de type const garde les littéraux, et la contrainte vérifie la complétude et le format de chaque couleurfunction defineStatusColors<const T extends Record<GovernanceHealthStatus, HexColor>>(colors: T): T { return colors;}
const colors = defineStatusColors({ error: '#FF4444', warning: '#FFB300', healthy: '#33CC66', unknown: '#888888', contradictory: '#FF44FF',});type _1 = Expect<Equal<(typeof colors)['healthy'], '#33CC66'>>;
function mistakes() { // @ts-expect-error: contradictory manque defineStatusColors({ error: '#FF4444', warning: '#FFB300', healthy: '#33CC66', unknown: '#888888' }); // @ts-expect-error: magenta n'est pas une couleur hexadécimale defineStatusColors({ error: '#FF4444', warning: '#FFB300', healthy: '#33CC66', unknown: '#888888', contradictory: 'magenta' });}console.log(colors.healthy, typeof mistakes);#33CC66 functionLe modificateur const garde les littéraux, et la contrainte joue le rôle de satisfies. La forme fonction était la façon habituelle d’obtenir ce résultat avant la 4.9, et elle reste utile quand la vérification a besoin d’un générique, par exemple une table dont les valeurs doivent être des clés d’un autre argument.
- Un formulaire produit
{ displayName: string | undefined; avatarUrl: string | null | undefined }, oùundefinedsignifie que le champ a été laissé vide. ÉcriswithoutUndefined(value), dont le résultat peut être étalé sur unViewerInfosousexactOptionalPropertyTypes.
Solution
solutions/l02_ex3_without_undefined.ts :
import type { Equal, Expect } from '../examples/type-tests.ts';
interface ViewerInfo { connectionId: string; color: string; displayName?: string; avatarUrl?: string | null;}
// Les propriétés qui peuvent contenir undefined deviennent optionnelles et perdent undefined ; null est gardé, puisque c'est une valeurtype WithoutUndefined<T> = { [K in keyof T]: Exclude<T[K], undefined> };
function withoutUndefined<T extends object>(value: T): Partial<WithoutUndefined<T>> { // Une assertion : le filtre retire exactement les entrées dont la valeur est undefined return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as Partial<WithoutUndefined<T>>;}
// Un patch construit à partir d'un formulaire, où un champ laissé vide vaut undefinedconst form: { displayName: string | undefined; avatarUrl: string | null | undefined } = { displayName: undefined, avatarUrl: null };const patch = withoutUndefined(form);type _1 = Expect<Equal<typeof patch, { displayName?: string; avatarUrl?: string | null }>>;
const viewer: ViewerInfo = { connectionId: 'c3', color: '#d2a8ff', displayName: 'Hari' };const updated: ViewerInfo = { ...viewer, ...patch };console.log(updated);{ connectionId: 'c3', color: '#d2a8ff', displayName: 'Hari', avatarUrl: null}WithoutUndefined retire undefined du type de chaque propriété et Partial rend chaque propriété optionnelle, ce qui est exactement ce que fait le filtre à l’exécution : une propriété est soit absente, soit porteuse d’une valeur. null est gardé, puisque c’est une valeur que le serveur envoie et que le type autorise. La sortie montre displayName intact, parce que le champ vide a été retiré au lieu d’être étalé sous forme de undefined.
- Handbook TypeScript — Compatibilité des types, Génériques — annotations de variance
- Notes de version : 2.4 — détection des types faibles, 4.7 — annotations de variance, 4.9 —
satisfies, 5.0 — paramètres de typeconst, 5.4 —NoInfer - Référence TSConfig — exactOptionalPropertyTypes
- DefinitelyTyped —
types/react/index.d.ts - Microsoft — Covariance et contravariance dans les génériques, Erreur du compilateur CS1961 ; The Java Tutorials — Wildcards, Inférence de type et types cibles