3. Unions et narrowing
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/l03_* et errors/l03_*, et les côtés C# et Java dans compare/l03_switch.cs, compare/l03_cast.cs, compare/L03Cast.java et compare_fail/L03Sealed.java.
C# et Java modélisent « une chose parmi plusieurs » avec une hiérarchie de classes : une base abstraite, une sous-classe par cas, et une méthode virtuelle ou un pattern matching pour les distinguer. JavaScript n’a pas de telle hiérarchie pour la plupart de ses valeurs : une case est un nombre ou la chaîne 'open', un événement est un objet dont la propriété kind dit ce qu’il est. TypeScript décrit ces valeurs avec des types union, et suit les vérifications de ton code pour savoir quel membre est une valeur à chaque ligne. Cette seconde partie, le narrowing, le rétrécissement du type, est le sujet de cette leçon (manuel : narrowing).
Types union
Section intitulée « Types union »A | B est une valeur qui est un A ou un B. Avant une vérification, seul ce que possèdent tous les membres est permis :
type Fret = number | 'open' | 'muted';
function describe(fret: Fret): string { return `fret ${fret.toFixed(0)}`;}
console.log(describe(3), describe('open'));> npx tsc -p out/tsconfig.l03_union_members.json --prettyerrors/l03_union_members.ts:5:23 - error TS2339: Property 'toFixed' does not exist on type 'Fret'. Property 'toFixed' does not exist on type '"muted"'.
5 return `fret ${fret.toFixed(0)}`; ~~~~~~~
Found 1 error in errors/l03_union_members.ts:5> node errors/l03_union_members.tserrors/l03_union_members.ts:5 return `fret ${fret.toFixed(0)}`; ^
TypeError: fret.toFixed is not a function
Node.js v24.21.0number a toFixed, 'open' et 'muted' non, donc Fret non plus. Le message nomme un des membres auxquels la propriété manque. Une union de types littéraux, 'open' | 'muted', est aussi la façon dont TypeScript écrit ce dont C# et Java feraient un enum, sans l’objet d’exécution que Node.js refuse de supprimer (leçon 1).
Le narrowing
Section intitulée « Le narrowing »import { show } from './show.ts';
// Une union : une case est un nombre, ou 'open', ou 'muted'type Fret = number | 'open' | 'muted';
function describe(fret: Fret): string { if (typeof fret === 'number') { return `fret ${fret.toFixed(0)}`; // ici fret est un number } return fret === 'open' ? 'open string' : 'not played'; // ici fret est 'open' | 'muted'}show('describe(3)', describe(3));show("describe('muted')", describe('muted'));
// La truthiness restreint aussi, et 0 est falsy : la corde à vide jouée à la case 0 disparaîtfunction label(fret: number | undefined): string { if (fret) return `fret ${fret}`; return 'no fret';}show('label(0)', label(0));show('label(undefined)', label(undefined));
// in, instanceof et Array.isArrayinterface Note { pitch: number;}interface Chord { pitches: number[];}function lowest(event: Note | Chord): number { return 'pitch' in event ? event.pitch : Math.min(...event.pitches);}show('lowest({ pitch: 40 })', lowest({ pitch: 40 }));show('lowest({ pitches: [52, 45] })', lowest({ pitches: [52, 45] }));
function message(error: unknown): string { if (error instanceof Error) return error.message; if (Array.isArray(error)) return `${error.length} errors`; return String(error);}show("message(new RangeError('fret 25'))", message(new RangeError('fret 25')));show("message(['a', 'b'])", message(['a', 'b']));
// Flux de contrôle : après un return ou un throw, la suite de la fonction en sait davantagefunction parseFret(text: string): Fret { if (text === 'o') return 'open'; if (text === 'x') return 'muted'; const fret = Number(text); if (!Number.isInteger(fret) || fret < 0) throw new RangeError(`not a fret: ${text}`); return fret;}show("'x32010'.split('').map(parseFret)", 'x32010'.split('').map(parseFret));describe(3) 'fret 3'describe('muted') 'not played'label(0) 'no fret'label(undefined) 'no fret'lowest({ pitch: 40 }) 40lowest({ pitches: [52, 45] }) 45message(new RangeError('fret 25')) 'fret 25'message(['a', 'b']) '2 errors''x32010'.split('').map(parseFret) [ 'muted', 3, 2, 0, 1, 0 ]Chaque vérification que JavaScript peut exécuter devient une information pour tsc :
| Vérification | Restreint à | Équivalent C# / Java |
|---|---|---|
typeof fret === 'number' |
number, et 'open' | 'muted' dans le else |
fret is int / fret instanceof Integer |
fret === 'open' |
le type littéral 'open' |
== sur une constante |
if (fret) |
retire undefined, null, et les types littéraux 0, '', false |
— |
'pitch' in event |
les membres qui déclarent pitch |
— |
error instanceof Error |
Error |
error is Exception e / error instanceof Exception e |
Array.isArray(error) |
any[] |
— |
return, throw |
la suite de la fonction, sans les cas qui en sont sortis | affectation définie, analyse de flux |
Deux de ces vérifications méritent un avertissement. La truthiness restreint number | undefined à number, et le if (fret) de label envoie aussi la case 0, la corde à vide, dans la branche 'no fret' : tsc l’accepte, parce que 0 est un number, et le programme est faux. Les règles sont celles de la leçon 2 de JavaScript ; compare avec !== undefined chaque fois que 0 ou '' est une valeur valide. in vérifie une propriété à l’exécution, et le typage structurel permet à un objet d’avoir plus de propriétés que son type n’en annonce : un objet Chord qui porterait aussi un pitch prendrait la branche Note. in est fiable quand les membres de l’union ne peuvent pas avoir les propriétés les uns des autres, et c’est ce que construit la section suivante.
tsc suit le code comme le fait l’analyse d’affectation définie de C#, à travers return, throw, &&, || et ?: : dans parseFret, fret est un number après les deux retours anticipés, et le throw garantit que c’est un entier positif ou nul, un fait que le type number ne peut pas exprimer.
Unions discriminées
Section intitulée « Unions discriminées »Quand chaque membre d’une union a la même propriété avec un type littéral différent, vérifier cette propriété restreint l’union à un seul membre. Cette propriété est le discriminant, ici kind :
import { show } from './show.ts';
// Une union discriminée : chaque membre a un kind, avec un type littéral différenttype MusicEvent = | { kind: 'note'; pitch: number; beats: number } | { kind: 'chord'; pitches: number[]; beats: number } | { kind: 'rest'; beats: number };
function assertNever(value: never): never { throw new Error(`unexpected event: ${JSON.stringify(value)}`);}
function describe(event: MusicEvent): string { switch (event.kind) { case 'note': return `note ${event.pitch} for ${event.beats}`; case 'chord': return `chord of ${event.pitches.length} for ${event.beats}`; case 'rest': return `rest for ${event.beats}`; default: return assertNever(event); // ici event est never : chaque kind est traité }}
const bar: MusicEvent[] = [ { kind: 'chord', pitches: [48, 52, 55], beats: 2 }, { kind: 'note', pitch: 60, beats: 1 }, { kind: 'rest', beats: 1 },];for (const event of bar) show(event.kind, describe(event));
// La vérification que tsc a faite à la compilation s'exécute toujours, pour les données qui ne sont pas passées par tscconst fromServer = JSON.parse('{"kind": "tie", "beats": 1}') as MusicEvent;try { describe(fromServer);} catch (err) { show('describe(fromServer)', err instanceof Error ? err.message : err);}chord 'chord of 3 for 2'note 'note 60 for 1'rest 'rest for 1'describe(fromServer) 'unexpected event: {"kind":"tie","beats":1}'Dans case 'note':, event est { kind: 'note'; pitch: number; beats: number }, et event.pitch compile ; dans case 'rest':, ce ne serait pas le cas. Après les trois cas, il ne reste rien : event a le type never, et assertNever(event) compile parce qu’un never est assignable au paramètre never. À l’exécution, la branche default existe toujours, et elle attrape ce que les types n’ont pas vu : fromServer a été affirmé comme un MusicEvent, est arrivé avec le kind 'tie', et a atteint assertNever.
Une union discriminée est la version TypeScript d’une hiérarchie fermée, un abstract record C# avec ses records dérivés, ou une sealed interface Java avec ses implémentations record. Les données sont de simples objets, ce que renvoie JSON.parse et ce qu’envoie un serveur.
Exhaustivité
Section intitulée « Exhaustivité »Ajoute un membre, et chaque switch qui ne le traite pas devrait échouer à la compilation. Avec assertNever dans le default, ou avec un type de retour déclaré, c’est le cas :
// Un nouveau type d'événement : les deux fonctions qui ne le traitent pas sont maintenant des erreurstype MusicEvent = | { kind: 'note'; pitch: number; beats: number } | { kind: 'chord'; pitches: number[]; beats: number } | { kind: 'rest'; beats: number } | { kind: 'tie'; beats: number };
function assertNever(value: never): never { throw new Error(`unexpected event: ${JSON.stringify(value)}`);}
function describe(event: MusicEvent): string { switch (event.kind) { case 'note': return `note ${event.pitch}`; case 'chord': return `chord of ${event.pitches.length}`; case 'rest': return 'rest'; default: return assertNever(event); }}
function beatsOf(event: MusicEvent): number { switch (event.kind) { case 'note': case 'chord': case 'rest': return event.beats; }}
console.log(describe({ kind: 'tie', beats: 1 }), beatsOf({ kind: 'tie', beats: 1 }));> npx tsc -p out/tsconfig.l03_exhaustive.json --prettyerrors/l03_exhaustive.ts:22:26 - error TS2345: Argument of type '{ kind: "tie"; beats: number; }' is not assignable to parameter of type 'never'.
22 return assertNever(event); ~~~~~
errors/l03_exhaustive.ts:26:38 - error TS2366: Function lacks ending return statement and return type does not include 'undefined'.
26 function beatsOf(event: MusicEvent): number { ~~~~~~
Found 2 errors in the same file, starting at: errors/l03_exhaustive.ts:22> node errors/l03_exhaustive.tserrors/l03_exhaustive.ts:10 throw new Error(`unexpected event: ${JSON.stringify(value)}`); ^
Error: unexpected event: {"kind":"tie","beats":1}
Node.js v24.21.0La première erreur dit qu’un événement tie atteint un paramètre qui n’accepte rien. La seconde vient du type de retour : beatsOf promet un number, et un événement tie passerait à travers le switch et renverrait undefined. Sans assertNever, une fonction sans type de retour déclaré, ou qui renvoie void, compile sans message (vérification d’exhaustivité).
Le même changement en C#, avec une expression switch sur des records, donne un avertissement :
> dotnet run l03_switch.cscompare/l03_switch.cs(10,43): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern '_' is not covered.chord of 3note 60SwitchExpressionExceptionC# ne peut pas savoir qu’aucune autre classe ne dérive de MusicEvent, donc il demande un cas _, compile quand même, et lève SwitchExpressionException sur le Tie. Les interfaces scellées de Java ferment la hiérarchie, et un switch avec patterns sur l’une d’elles est vérifié comme TypeScript vérifie une union :
> javac L03Sealed.javaL03Sealed.java:10: error: the switch expression does not cover all possible input values return switch (e) { ^1 error| C# | Java | TypeScript | |
|---|---|---|---|
| Ensemble fermé de cas | pas de hiérarchies fermées en C# 14 : un cas _ est attendu |
sealed interface … permits |
un type union |
| Cas manquant | avertissement CS8509, SwitchExpressionException à l’exécution |
erreur de compilation | erreur, si le switch se termine par assertNever ou si la fonction déclare son type de retour |
| Données venues de l’extérieur | désérialisées dans les classes | désérialisées dans les records | de simples objets : le discriminant doit être vérifié |
as ne vérifie rien
Section intitulée « as ne vérifie rien »Les casts de C# et de Java sont vérifiés à l’exécution. Une assertion de type TypeScript, value as T, est un message adressé à tsc, et disparaît avec les autres types :
// examples/l03_guards.ts, lignes 11-13const trusted = JSON.parse('{"px": 1, "py": 2}') as CameraState;show('trusted.pz', trusted.pz);show('trusted.pz * 2', trusted.pz * 2);trusted.pz undefinedtrusted.pz * 2 NaN> dotnet run l03_cast.cs(CameraState)parsed: InvalidCastExceptionparsed as CameraState: Trueparsed is CameraState: False> java L03Cast.java(CameraState) parsed: ClassCastExceptionparsed instanceof CameraState: falseLe (CameraState)parsed de C# lève InvalidCastException, as renvoie null, et le cast de Java lève ClassCastException : le runtime connaît la classe de chaque objet. Un objet JavaScript parsé depuis du JSON n’a pas de classe à laquelle se comparer, et as n’essaie pas. tsc ne refuse une assertion qu’entre deux types qui ne se recouvrent pas du tout, et as unknown as T passe par unknown pour contourner ce refus (assertions de type). Le NaN sorti de trusted.pz * 2 est le genre de valeur qui voyage loin de la ligne qui l’a produite.
Prédicats de type et fonctions d’assertion
Section intitulée « Prédicats de type et fonctions d’assertion »Une vérification que tsc ne peut pas lire dans une seule expression va dans une fonction dont le type de retour dit ce qu’elle prouve :
import { attempt, show } from './show.ts';
interface CameraState { px: number; py: number; pz: number;}
// as n'est pas un cast : il ne convertit rien et ne vérifie rienconst trusted = JSON.parse('{"px": 1, "py": 2}') as CameraState;show('trusted.pz', trusted.pz);show('trusted.pz * 2', trusted.pz * 2);
// Un prédicat de type : une fonction qui renvoie un booléen, et dit à tsc ce que true signifiefunction isCameraState(value: unknown): value is CameraState { return ( typeof value === 'object' && value !== null && 'px' in value && typeof value.px === 'number' && // après 'px' in value, tsc sait que value a une propriété px de type unknown 'py' in value && typeof value.py === 'number' && 'pz' in value && typeof value.pz === 'number' );}
function restore(saved: string): CameraState { const value: unknown = JSON.parse(saved); return isCameraState(value) ? value : { px: 0, py: 0, pz: 100 };}show("restore('{\"px\":1,\"py\":2}')", restore('{"px":1,"py":2}'));show("restore('{\"px\":1,\"py\":2,\"pz\":3}')", restore('{"px":1,"py":2,"pz":3}'));
// Une fonction d'assertion : elle ne revient que si la condition est vraie, et lève une exception sinontype Mode = 'ionian' | 'dorian' | 'phrygian';const modes: readonly string[] = ['ionian', 'dorian', 'phrygian'];function assertMode(value: string): asserts value is Mode { if (!modes.includes(value)) throw new RangeError(`unknown mode: ${value}`);}function brightness(mode: Mode): number { return modes.length - modes.indexOf(mode);}const fromUrl = new URLSearchParams('?mode=dorian').get('mode') ?? 'ionian';assertMode(fromUrl);show('brightness(fromUrl)', brightness(fromUrl)); // fromUrl est un Mode après l'assertionattempt("assertMode('locrian')", () => assertMode('locrian'));
// tsc fait confiance à un prédicat sans le lire : un prédicat faux est un mensonge qui compilefunction isCameraStateLie(value: unknown): value is CameraState { return value !== null;}const lie: unknown = JSON.parse('"not a camera"');if (isCameraStateLie(lie)) { attempt('lie.px.toFixed(1)', () => lie.px.toFixed(1));}trusted.pz undefinedtrusted.pz * 2 NaNrestore('{"px":1,"py":2}') { px: 0, py: 0, pz: 100 }restore('{"px":1,"py":2,"pz":3}') { px: 1, py: 2, pz: 3 }brightness(fromUrl) 2assertMode('locrian') RangeError: unknown mode: locrianlie.px.toFixed(1) TypeError: Cannot read properties of undefined (reading 'toFixed')value is CameraState, un prédicat de type, renvoie unboolean, et dans la branche où il a renvoyétrue, l’argument est unCameraState. DansisCameraState, chaque'px' in valuerestreintvalueà un objet doté d’une propriétépxde typeunknown, quetypeof value.px === 'number'restreint ensuite ànumber: la fonction compile sans un seulas.asserts value is Mode, une fonction d’assertion, ne revient que si la condition est vraie, et lève une exception sinon ; après l’appel, la variable a le type plus étroit pour le reste de la portée, comme après unthrowdans la fonction elle-même. La fonction doit être déclarée avec un type explicite, une déclarationfunctionou uneconstannotée, pour quetscs’en serve.tscne lit pas le corps d’un prédicat.isCameraStateLievérifie seulement que la valeur n’est pasnull, compile, et fait croire àtscqu’une chaîne est une caméra. Un prédicat est une assertion enveloppée dans une fonction : ce sont les vérifications qu’il contient qui le rendent vrai, et elles méritent leurs propres tests.
C’est le modèle de la frontière pour les données venues de l’extérieur du programme, JSON.parse, localStorage, fetch, un message SignalR : un unknown à l’entrée, un prédicat ou une conversion qui le vérifie, et des types précis à l’intérieur.
Dans GuitarAlchemist/ga : des statuts que le type exclut
Section intitulée « Dans GuitarAlchemist/ga : des statuts que le type exclut »Le Prime Radiant de GA, un graphe de gouvernance dans ga-react-components, déclare les statuts de santé que peut avoir un nœud dans types.ts, ligne 34 :
export type GovernanceHealthStatus = 'error' | 'warning' | 'healthy' | 'unknown' | 'contradictory';ForceRadiant.tsx, lignes 720-729, choisit une prédiction à partir de ce statut, et teste aussi 'ok' et 'critical'. tsc 7.0.2 signale les deux, parmi les erreurs de la leçon 1 ; le cours reproduit la fonction avec le même type :
type GovernanceHealthStatus = 'error' | 'warning' | 'healthy' | 'unknown' | 'contradictory';interface GovernanceNode { id: string; healthStatus?: GovernanceHealthStatus;}
function prediction(node: GovernanceNode): string { const status = node.healthStatus ?? 'unknown'; if (status === 'healthy' || status === 'ok') return 'stable'; if (status === 'warning') return 'at risk'; if (status === 'error' || status === 'critical') return 'failing'; return 'uncertain';}
console.log(prediction({ id: 'policy-7' }));> npx tsc -p out/tsconfig.l03_ga_health.json --prettyerrors/l03_ga_health.ts:10:31 - error TS2367: This comparison appears to be unintentional because the types '"contradictory" | "error" | "unknown" | "warning"' and '"ok"' have no overlap.
10 if (status === 'healthy' || status === 'ok') return 'stable'; ~~~~~~~~~~~~~~~
errors/l03_ga_health.ts:12:29 - error TS2367: This comparison appears to be unintentional because the types '"contradictory" | "unknown"' and '"critical"' have no overlap.
12 if (status === 'error' || status === 'critical') return 'failing'; ~~~~~~~~~~~~~~~~~~~~~
Found 2 errors in the same file, starting at: errors/l03_ga_health.ts:10Les messages montrent le narrowing à l’œuvre : une fois que status === 'healthy' a échoué, status ne peut plus être 'healthy', et après la ligne warning, il ne peut plus être 'warning' non plus. Aucune des deux comparaisons ne peut jamais être vraie pour une valeur de ce type. À l’exécution, pourtant, elle le peut, à cause de la provenance des données. DataLoader.ts, lignes 276-278, reçoit le message NodeChanged du client JavaScript de SignalR avec healthStatus: string, et le transmet sous la forme data as unknown as GovernanceNode :
import { show } from './show.ts';
// types.ts, ligne 34 : les statuts que connaît le front endtype GovernanceHealthStatus = 'error' | 'warning' | 'healthy' | 'unknown' | 'contradictory';interface GovernanceNode { id: string; healthStatus?: GovernanceHealthStatus;}
// DataLoader.ts, lignes 276-278 : un message SignalR typé avec une string, transmis avec une double assertionfunction onNodeChanged(data: { nodeId: string; healthStatus: string }): GovernanceNode { return data as unknown as GovernanceNode;}
// ForceRadiant.tsx, lignes 720-729, réduit : les statuts 'ok' et 'critical' ne peuvent pas être dans l'unionfunction prediction(node: GovernanceNode): string { const status: string = node.healthStatus ?? 'unknown'; // élargi à string, sinon tsc signale TS2367 plus bas if (status === 'healthy' || status === 'ok') return 'stable'; if (status === 'warning') return 'at risk'; if (status === 'error' || status === 'critical') return 'failing'; return 'uncertain';}
const node = onNodeChanged({ nodeId: 'policy-7', healthStatus: 'critical' });show('node.healthStatus', node.healthStatus);show('prediction(node)', prediction(node));const known: readonly string[] = ['error', 'warning', 'healthy', 'unknown', 'contradictory'];show('known.includes(node.healthStatus)', known.includes(node.healthStatus ?? 'unknown'));node.healthStatus 'critical'prediction(node) 'failing'known.includes(node.healthStatus) falseUn 'critical' venu du serveur atteint un GovernanceNode dont le type dit qu’il ne peut pas être 'critical', et la comparaison que tsc qualifie de non intentionnelle est celle qui le traite. Deux lectures sont possibles, et le code ne dit pas laquelle est la bonne : le serveur envoie ok et critical (à vérifier dans le hub de GA), et il manque deux membres à l’union ; ou il ne les envoie pas, et les comparaisons sont du code mort. Dans les deux cas, la double assertion est l’endroit où le type a cessé de décrire les données. Le troisième exercice convertit plutôt le statut à cette frontière. ga-react-components contient 34 as unknown as, et le même fichier restaure la caméra avec JSON.parse(saved) as { px: number; … }, ligne 3558, le modèle de la section sur as : une valeur enregistrée à laquelle manque une coordonnée passerait undefined à fg.cameraPosition (à vérifier dans un navigateur) ; le deuxième exercice la vérifie.
L’ordre des unions dans les messages
Section intitulée « L’ordre des unions dans les messages »TypeScript 5.9.3, sur le même fichier de GA, affichait la première union sous la forme "warning" | "error" | "unknown" | "contradictory" ; 7.0.2 affiche "contradictory" | "error" | "unknown" | "warning". Ni l’un ni l’autre ne suit l’ordre de la déclaration. Jusqu’à 6.0, les membres d’une union étaient triés par identifiants de type internes, attribués dans l’ordre où le vérificateur rencontrait les types ; TypeScript 7 vérifie les fichiers en parallèle et trie plutôt les types selon leur contenu, pour que la sortie ne dépende pas du thread qui a vu un type en premier (notes de version 6.0, --stableTypeOrdering). N’écris pas de test qui compare le texte d’une union dans un message ou un .d.ts d’une version à l’autre.
Sur ce site : une union inférée à partir de JSON
Section intitulée « Sur ce site : une union inférée à partir de JSON »Le fichier astro.config.mjs de ce site commence par // @ts-check, qui demande à tsc de vérifier un fichier JavaScript, et importe le fichier généré streeling-sidebar.json dans la barre latérale de Starlight. Lancer tsc 7.0.2 sur le site à ce commit, avec le tsconfig.json du site lui-même, le signale :
astro.config.mjs(146,5): error TS2322: Type '{ label: string; collapsed: true; items: ({ label: string; translations: { fr: string; es: string; }; slug: string; collapsed?: undefined; items?: undefined; } | { label: string; translations: { es: string; fr?: undefined; }; slug: string; collapsed?: undefined; items?: undefined; } | { ...; })[]; }' is not assignable to type 'SidebarItemUserConfig'. Types of property 'items' are incompatible. […] Types of property 'translations' are incompatible. Type '{ es: string; fr?: undefined; }' is not assignable to type 'Record<string, string>'. Property '"fr"' is incompatible with index signature. Type 'undefined' is not assignable to type 'string'.Un import JSON reçoit le type de son contenu, inféré comme pour un littéral objet. Le tableau contient des entrées de formes différentes, donc son type d’élément est une union, et tsc normalise les types des littéraux objets dans une union : une propriété qu’un membre possède et qu’un autre n’a pas est ajoutée à l’autre comme optionnelle et undefined, pour pouvoir être lue sur chaque membre. L’entrée Journal n’a qu’une traduction espagnole, reçoit fr?: undefined, et ne rentre plus dans un Record<string, string>. Rien ne se passe mal à l’exécution : le build Astro n’exécute pas tsc, et Starlight valide la barre latérale au chargement. L’exemple montre la limite de l’inférence pour les données : un type écrit par le programme, vérifié contre les données à la frontière, dit ce que le code attend, là où un type inféré dit seulement ce que le fichier contenait ce jour-là.
À retenir
Section intitulée « À retenir »- Une union ne permet que ce que tous ses membres ont ; une vérification la restreint, et
tscsuittypeof,===, la truthiness,in,instanceof,returnetthrow. - Le narrowing par truthiness retire aussi
0et'';inpeut être trompé par des propriétés en trop. - Une union discriminée, qui a une propriété littérale commune, est la hiérarchie fermée de TypeScript. Termine son
switchparassertNever, ou déclare le type de retour, et un nouveau membre devient une erreur de compilation, comme avec les interfaces scellées de Java et contrairement à l’avertissement de C#. asne vérifie rien, là où les casts de C# et de Java lèvent une exception ;as unknown asretire la dernière vérification que faittsc.- Un prédicat de type ou une fonction d’assertion restreint ce que
tscne peut pas suivre, ettscfait aveuglément confiance à son corps. - Les données venues de l’extérieur entrent comme
unknownet sont vérifiées ou converties une seule fois, à la frontière. - Ne dépends pas de l’ordre des membres d’une union dans les messages ou les fichiers de déclaration.
Exercices
Section intitulée « Exercices »- Ajoute un événement
{ kind: 'tie'; beats: number }auMusicEventdeerrors/l03_exhaustive.ts, fais compiler les deux fonctions, et calcule le total des temps d’une mesure.
Solution
type MusicEvent = | { kind: 'note'; pitch: number; beats: number } | { kind: 'chord'; pitches: number[]; beats: number } | { kind: 'rest'; beats: number } | { kind: 'tie'; beats: number };
function assertNever(value: never): never { throw new Error(`unexpected event: ${JSON.stringify(value)}`);}
function describe(event: MusicEvent): string { switch (event.kind) { case 'note': return `note ${event.pitch} for ${event.beats}`; case 'chord': return `chord of ${event.pitches.length} for ${event.beats}`; case 'rest': return `rest for ${event.beats}`; case 'tie': return `tie for ${event.beats}`; default: return assertNever(event); }}
// beatsOf n'a besoin d'aucun switch : chaque membre a beatsfunction beatsOf(event: MusicEvent): number { return event.beats;}
const bar: MusicEvent[] = [ { kind: 'note', pitch: 60, beats: 2 }, { kind: 'tie', beats: 1 }, { kind: 'rest', beats: 1 },];console.log(bar.map(describe), bar.reduce((sum, event) => sum + beatsOf(event), 0));[ 'note 60 for 2', 'tie for 1', 'rest for 1' ] 4beats est commun à tous les membres, donc event.beats compile sans narrowing, et beatsOf n’a plus de switch où oublier un cas.
- GA enregistre sa caméra sous forme de six nombres,
px,py,pzpour la position etlx,ly,lzpour la cible. ÉcrisrestoreCamera(saved: string | null): CameraState | undefined, qui renvoieundefinedpour une valeur absente, du JSON invalide, une coordonnée manquante, ou une coordonnée qui n’est pas un nombre fini, sansas.
Solution
interface CameraState { px: number; py: number; pz: number; lx: number; ly: number; lz: number;}const keys = ['px', 'py', 'pz', 'lx', 'ly', 'lz'] as const;
function isCameraState(value: unknown): value is CameraState { if (typeof value !== 'object' || value === null) return false; const record: { [key: string]: unknown } = { ...value }; return keys.every((key) => typeof record[key] === 'number' && Number.isFinite(record[key]));}
// undefined quand rien d'utilisable n'est enregistré : l'appelant garde sa caméra par défautfunction restoreCamera(saved: string | null): CameraState | undefined { if (saved === null) return undefined; try { const value: unknown = JSON.parse(saved); return isCameraState(value) ? value : undefined; } catch { return undefined; // pas du JSON du tout }}
const good = JSON.stringify({ px: 0, py: 50, pz: 300, lx: 0, ly: 0, lz: 0 });console.log(restoreCamera(good));console.log(restoreCamera(null), restoreCamera('{'), restoreCamera('{}'));console.log(restoreCamera('{"px":0,"py":0,"pz":"300","lx":0,"ly":0,"lz":0}'));{ px: 0, py: 50, pz: 300, lx: 0, ly: 0, lz: 0 }undefined undefined undefinedundefinedCopier l’objet dans un { [key: string]: unknown } avec un spread est ce qui permet au prédicat de boucler sur les clés : on sait seulement que value est un object, qui n’a pas de signature d’index, et la copie en a une dont toutes les valeurs sont unknown. Number.isFinite rejette aussi Infinity, que JSON.parse renvoie pour un nombre trop grand pour un double, comme 1e999 dans une valeur de localStorage modifiée à la main.
- Écris
toHealthStatus(text: string): GovernanceHealthStatus, qui convertit'ok'en'healthy','critical'en'error'et tout ce qui est inattendu en'unknown', ainsi qu’une table de prédictions donttscvérifie qu’elle est complète, sans chaîne deif.
Solution
type GovernanceHealthStatus = 'error' | 'warning' | 'healthy' | 'unknown' | 'contradictory';
// La frontière : chaque chaîne que le serveur peut envoyer devient l'un des statuts que connaît le front endfunction toHealthStatus(text: string): GovernanceHealthStatus { switch (text) { case 'healthy': case 'ok': return 'healthy'; case 'warning': return 'warning'; case 'error': case 'critical': return 'error'; case 'contradictory': return 'contradictory'; default: return 'unknown'; }}
// À l'intérieur, l'union dit vrai, et un Record indexé par elle doit lister chaque statutconst predictions: Record<GovernanceHealthStatus, string> = { healthy: 'stable', warning: 'at risk', error: 'failing', contradictory: 'uncertain', unknown: 'uncertain',};
for (const text of ['ok', 'critical', 'warning', 'purple']) { const status = toHealthStatus(text); console.log(text.padEnd(9), status.padEnd(8), predictions[status]);}ok healthy stablecritical error failingwarning warning at riskpurple unknown uncertainLes chaînes du serveur sont traitées dans une seule fonction, dont le type de retour est l’union : à l’intérieur du programme, status ne peut être que l’une des cinq valeurs, et predictions[status] n’a besoin d’aucune valeur de repli. Un Record<GovernanceHealthStatus, string> doit avoir une propriété pour chaque membre de l’union, donc un sixième statut ajouté au type fait de la table une erreur jusqu’à ce que quelqu’un décide de sa prédiction. La leçon 4 montre comment Record est construit.
- Manuel TypeScript — Narrowing, Types de tous les jours : types union et assertions de type, Types utilitaires
- Notes de version de TypeScript 3.7 — fonctions d’assertion, 2.7 — inférence des littéraux objets, 6.0 —
--stableTypeOrdering - Microsoft — expression switch, opérateurs de test de type et de cast
- Java — classes et interfaces scellées, pattern matching pour switch