4. Frontières à l'exécution — schémas et erreurs typées
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/l04_* et errors/l04_*, les bundles dans bundle/ avec bundle-size.mjs, et les côtés C# et Java dans compare/ (l04_hub_json.cs, l04_json_required.cs) et compare_fail/ (L04Checked.java).
Les trois premières leçons ont fait prouver davantage au vérificateur. Tout ce qu’il prouve repose sur une hypothèse : chaque valeur a le type écrit pour elle. À l’intérieur du programme, tsc le vérifie. À la frontière, là où les valeurs viennent de JSON.parse, de response.json(), d’un message SignalR ou de localStorage, rien ne le vérifie : ces API renvoient any, ou un paramètre typé à la main, et les types sont effacés avant que le programme s’exécute. Les développeurs C# et Java sont habitués à un désérialiseur qui construit au moins le type déclaré et échoue quand il n’y arrive pas. En TypeScript, un type faux à la frontière compile, s’exécute, et échoue ailleurs, ou pas du tout.
Une mise à jour perdue dans GA
Section intitulée « Une mise à jour perdue dans GA »Le hub C# de GA diffuse le changement de santé d’un nœud dans GovernanceHub.cs, lignes 163-176, sous la forme d’un objet anonyme dont la première propriété est nodeId. Le front end le reçoit dans DataLoader.ts, lignes 276-279 :
connection.on('NodeChanged', (data: { nodeId: string; health: unknown; healthStatus: string; color: string }) => { // Mise à jour partielle — un seul nœud onUpdate({ nodes: [data as unknown as GovernanceNode], edges: [], globalHealth: { resilienceScore: 0, lolliCount: 0, ergolCount: 0 }, timestamp: new Date().toISOString() } as GovernanceGraph);});Le type du paramètre est juste : il correspond au serveur. L’assertion as unknown as GovernanceNode affirme ensuite qu’un objet avec nodeId est un nœud, dont la clé est id. ForceRadiant.tsx, ligne 3585, passe les nœuds de ce graphe à updateNodeHealth, qui cherche chaque nœud de la scène par id parmi les nœuds reçus. Elle ne trouve rien, et la mise à jour est abandonnée sans erreur. Une recherche de code sur GitHub ne trouve aujourd’hui aucun appel à BroadcastNodeChanged, donc ce chemin est latent (à vérifier sur un serveur en marche) ; c’est exactement le genre de bug qui apparaît le jour où quelqu’un l’appelle.
L’exemple réduit le handler et la fonction de mise à jour à l’essentiel :
// Le handler NodeChanged de GuitarAlchemist/ga, réduit : la charge utile est affirmée être un nœud, et la mise à jour est perdueimport * as z from 'zod';import { show } from './show.ts';
interface HealthMetrics { resilienceScore: number; lolliCount: number; ergolCount: number;}interface GovernanceNode { id: string; name: string; health?: HealthMetrics;}
// DataLoader.ts, updateNodeHealth : cherche chaque nœud existant par id parmi les nœuds reçus, et met à jour sa santéfunction updateNodeHealth(existingNodes: GovernanceNode[], freshNodes: GovernanceNode[]): string[] { const freshMap = new Map(freshNodes.map((n) => [n.id, n])); const updated: string[] = []; for (const node of existingNodes) { const fresh = freshMap.get(node.id); if (!fresh?.health) continue; node.health = fresh.health; updated.push(node.id); } return updated;}
const scene: GovernanceNode[] = [{ id: 'policy-7', name: 'Alignment policy', health: { resilienceScore: 0.9, lolliCount: 0, ergolCount: 3 } }];
// Le message qu'envoie GovernanceHub.BroadcastNodeChanged, tel que SignalR le sérialise (compare/l04_hub_json.cs)const message = '{"nodeId":"policy-7","health":{"resilienceScore":0.4,"lolliCount":0,"ergolCount":3},"healthStatus":"warning","color":"#FFB300","timestamp":"2026-09-15T12:00:01Z"}';
// DataLoader.ts, lignes 276-279 : le paramètre du handler est annoté, puis affirmé être un GovernanceNodeconst data: { nodeId: string; health: unknown; healthStatus: string; color: string } = JSON.parse(message);const asserted = updateNodeHealth(scene, [data as unknown as GovernanceNode]);show('updated (asserted)', asserted);show('scene[0].health.resilienceScore', scene[0]?.health?.resilienceScore);
// Un schéma vérifie la même charge utile à la frontière, et dit ce qui ne va pasconst HealthMetricsSchema = z.object({ resilienceScore: z.number().min(0).max(1), lolliCount: z.int().nonnegative(), ergolCount: z.int().nonnegative(),});const GovernanceNodeSchema = z.object({ id: z.string().min(1), name: z.string(), health: HealthMetricsSchema.optional(),});const asNode = GovernanceNodeSchema.safeParse(JSON.parse(message));if (!asNode.success) console.log(z.prettifyError(asNode.error));
// Le schéma de ce que le hub envoie vraiment, et la conversion en nœud écrite une seule foisconst NodeChangedSchema = z.object({ nodeId: z.string().min(1), health: HealthMetricsSchema, healthStatus: z.enum(['error', 'warning', 'healthy', 'unknown', 'contradictory']), color: z.string().regex(/^#[0-9A-F]{6}$/i), timestamp: z.iso.datetime(),});const changed = NodeChangedSchema.parse(JSON.parse(message));const updated = updateNodeHealth(scene, [{ id: changed.nodeId, name: '', health: changed.health }]);show('updated (validated)', updated);show('scene[0].health.resilienceScore', scene[0]?.health?.resilienceScore);updated (asserted) []scene[0].health.resilienceScore 0.9✖ Invalid input: expected string, received undefined → at id✖ Invalid input: expected string, received undefined → at nameupdated (validated) [ 'policy-7' ]scene[0].health.resilienceScore 0.4Avec l’assertion, updated est vide et le score reste à 0,9. Le premier schéma, GovernanceNodeSchema, est le type que le code supposait, et il dit tout de suite ce qui ne va pas dans le message : pas d’id, pas de name. Le second schéma décrit ce que le hub envoie, et la conversion d’un message en nœud est écrite une seule fois, après la vérification ; la mise à jour passe.
Ce que le hub envoie exactement est une question pour le côté C#, et un programme C# y répond avec le sérialiseur de SignalR lui-même :
// Ce que GovernanceHub.cs met sur le fil : le protocole de hub JSON de SignalR, avec ses options de sérialisation par défaut#:sdk Microsoft.NET.Sdk.Web// Les applications basées sur un fichier sont prêtes pour l'AOT par défaut, ce qui désactive le JSON par réflexion : les types anonymes en ont besoin#:property PublishAot=falseusing System.Buffers;using System.Text;using Microsoft.AspNetCore.SignalR.Protocol;
var protocol = new JsonHubProtocol();
// BroadcastNodeChanged envoie un objet anonyme dont la première propriété est nodeId, et non idvar nodeChanged = new{ nodeId = "policy-7", health = new HealthMetrics(0.4, 0, 3), healthStatus = "warning", color = "#FFB300", timestamp = new DateTime(2026, 9, 15, 12, 0, 1, DateTimeKind.Utc),};Console.WriteLine(Write(new InvocationMessage("NodeChanged", [nodeChanged])));
// ViewersChanged envoie des records ViewerInfo : les propriétés en PascalCase passent en camelCase, et null est écrit nullvar viewer = new ViewerInfo("c3", "#d2a8ff", "Firefox", new DateTime(2026, 9, 15, 12, 0, 0, DateTimeKind.Utc));Console.WriteLine(Write(new InvocationMessage("ViewersChanged", [new List<ViewerInfo> { viewer }])));
string Write(HubMessage message){ var buffer = new ArrayBufferWriter<byte>(); protocol.WriteMessage(message, buffer); // Chaque message se termine par le caractère séparateur d'enregistrements, 0x1E return Encoding.UTF8.GetString(buffer.WrittenSpan).TrimEnd((char)0x1E);}
record HealthMetrics(double ResilienceScore, int LolliCount, int ErgolCount);record ViewerInfo(string ConnectionId, string Color, string Browser, DateTime ConnectedAt, string? DisplayName = null, string? AvatarUrl = null);> dotnet run l04_hub_json.cs{"type":1,"target":"NodeChanged","arguments":[{"nodeId":"policy-7","health":{"resilienceScore":0.4,"lolliCount":0,"ergolCount":3},"healthStatus":"warning","color":"#FFB300","timestamp":"2026-09-15T12:00:01Z"}]}{"type":1,"target":"ViewersChanged","arguments":[[{"connectionId":"c3","color":"#d2a8ff","browser":"Firefox","connectedAt":"2026-09-15T12:00:00Z","displayName":null,"avatarUrl":null}]]}Le protocole de hub JSON écrit les arguments avec System.Text.Json et des noms de propriétés en camelCase par défaut. L’objet anonyme garde nodeId. Le second message est le ViewersChanged de GA : le string? DisplayName = null du record est écrit "displayName":null. Le ViewerInfo du front end déclare displayName?: string, dont la leçon 2 a montré que c’est un type différent de string | null. Le code de GA fonctionne parce que chaque lecture utilise ?. ou ??, qui traitent null et undefined de la même façon.
Deux détails du programme C# sont bons à savoir. Les applications basées sur un fichier sont publiées en AOT natif par défaut, ce qui désactive le JSON par réflexion ; les types anonymes en ont besoin, d’où #:property PublishAot=false. Et le on du client JavaScript est déclaré on(methodName: string, newMethod: (...args: any[]) => any) : chaque type de paramètre d’un handler est une affirmation que rien ne vérifie.
flowchart LR hub["hub C# : SendAsync avec un objet"] --> wire["texte JSON sur le WebSocket"] wire --> on["connection.on : arguments typés any"] on -->|"as GovernanceNode"| trust["type supposé : les mauvaises clés passent"] on -->|"schema.safeParse"| check{"valide ?"} check -->|oui| typed["valeur typée"] check -->|non| issues["problèmes : journalisés ou signalés"]La bibliothèque de composants de GA compte 42 appels à JSON.parse, 55 lignes qui affirment le résultat de response.json() avec as, et 34 as unknown as hors des tests, au commit 32f143c. Ce ne sont pas toutes des frontières, et elles ne sont pas toutes fausses ; chacune est un endroit où l’on croit un type au lieu de le vérifier.
Schémas : une définition, deux vérifications
Section intitulée « Schémas : une définition, deux vérifications »Une bibliothèque de schémas décrit les données avec des valeurs, vérifie par rapport à elles les données inconnues à l’exécution, et calcule le type TypeScript à partir de la même description, avec les techniques de la leçon 1. Ce cours utilise Zod 4 :
import * as z from 'zod';import type { Equal, Expect } from './type-tests.ts';import { show } from './show.ts';
// 1. Le type dérivé du schéma : une seule définition, vérifiée à l'exécution et à la compilationconst ViewerInfoSchema = z.object({ connectionId: z.string(), color: z.string(), browser: z.enum(['Edge', 'Chrome', 'Firefox', 'Safari', 'Unknown']), connectedAt: z.iso.datetime(), displayName: z.string().nullable(), // le string? DisplayName = null du record C# est envoyé comme null avatarUrl: z.string().nullable(),});type ViewerInfo = z.infer<typeof ViewerInfoSchema>;type _1 = Expect<Equal<ViewerInfo['displayName'], string | null>>;
// 2. Ou le schéma vérifié par rapport à une interface écrite à la main : satisfies z.ZodType<T> échoue s'ils divergent.// Le ViewerInfo de GA dans DataLoader.ts déclare displayName?: string, où null n'entre pas (errors/l04_schemas.ts)interface ViewerInfoFixed { connectionId: string; color: string; browser: 'Edge' | 'Chrome' | 'Firefox' | 'Safari' | 'Unknown'; connectedAt: string; displayName: string | null; avatarUrl: string | null;}const CheckedViewerInfoSchema = ViewerInfoSchema satisfies z.ZodType<ViewerInfoFixed>;type _2 = Expect<Equal<z.infer<typeof CheckedViewerInfoSchema>, ViewerInfoFixed>>;
// 3. Les types d'entrée et de sortie diffèrent dès que le schéma transforme : des chaînes dans le message, une Date et un nombre dans le programmeconst CameraSyncSchema = z.object({ px: z.number(), py: z.number(), pz: z.number(), sender: z.string(), sentAt: z.iso.datetime().transform((text) => new Date(text)), zoom: z.coerce.number().default(1),});type CameraSyncInput = z.input<typeof CameraSyncSchema>;type CameraSync = z.output<typeof CameraSyncSchema>;type _3 = Expect<Equal<CameraSyncInput['sentAt'], string>>;type _4 = Expect<Equal<CameraSync['sentAt'], Date>>;type _5 = Expect<Equal<CameraSync['zoom'], number>>;const camera = CameraSyncSchema.parse({ px: 0, py: 2, pz: 10, sender: 'c3', sentAt: '2026-09-15T12:00:00Z', zoom: '1.5' });show('camera.sentAt.getUTCHours()', camera.sentAt.getUTCHours());show('camera.zoom', camera.zoom);
// 4. Un brand ajouté par le schéma : la seule façon d'obtenir un NodeId est de passer la vérification (leçon 3)const NodeIdSchema = z.string().regex(/^[a-z0-9-]+$/).brand<'NodeId'>();type NodeId = z.infer<typeof NodeIdSchema>;const nodeId: NodeId = NodeIdSchema.parse('policy-7');show('nodeId', nodeId);show("safeParse('Policy 7').success", NodeIdSchema.safeParse('Policy 7').success);
// 5. Les clés inconnues : retirées par défaut, gardées avec z.looseObject, rejetées avec z.strictObjectconst payload = { connectionId: 'c3', color: '#d2a8ff', browser: 'Firefox', connectedAt: '2026-09-15T12:00:00Z', displayName: null, avatarUrl: null, isAdmin: true };show("'isAdmin' in parse(payload)", 'isAdmin' in ViewerInfoSchema.parse(payload));const strict = z.strictObject(ViewerInfoSchema.shape).safeParse(payload);show('strictObject issues', strict.error?.issues.map((issue) => `${issue.code}: ${issue.message}`));
// 6. Le schéma en JSON Schema, le format qu'utilisent les documents OpenAPI et les générateurs C# ou Javashow('z.toJSONSchema(NodeIdSchema)', z.toJSONSchema(NodeIdSchema));camera.sentAt.getUTCHours() 12camera.zoom 1.5nodeId 'policy-7'safeParse('Policy 7').success false'isAdmin' in parse(payload) falsestrictObject issues [ 'unrecognized_keys: Unrecognized key: "isAdmin"' ]z.toJSONSchema(NodeIdSchema) { '$schema': 'https://json-schema.org/draft/2020-12/schema', type: 'string', pattern: '^[a-z0-9-]+$'}- Types dérivés.
z.inferest le type de sortie du schéma. Le test de types vérifie quedisplayNameeststring | null, comme le hub l’envoie. - Un schéma vérifié par rapport à une interface. Quand l’interface vient en premier, par exemple générée à partir d’un document OpenAPI,
satisfies z.ZodType<ViewerInfoFixed>échoue dès que les deux divergent. Avec leViewerInfode GA lui-même, il échoue, et c’est la divergence de la section précédente trouvée par le compilateur. - Entrée et sortie. Un
transformou uncoercerend le type du message différent du type du programme :sentAtest unestringdansz.inputet uneDatedansz.output. Un schéma est un parseur, pas seulement un validateur. - Brands.
.brand<'NodeId'>()ajoute le brand de la leçon 3 au type de sortie, donc le constructeur intelligent est le schéma lui-même. - Clés inconnues.
z.objectles retire, ce qui protège le code qui étale le résultat dans une requête ;z.strictObjectles rejette. - JSON Schema.
z.toJSONSchematransforme le schéma dans le format que lisent OpenAPI et les générateurs C# et Java, donc le schéma peut être le contrat plutôt qu’une copie de celui-ci.
Les erreurs :
> npx tsc -p out/tsconfig.l04_schemas.json --prettyerrors/l04_schemas.ts:23:34 - error TS1360: Type 'ZodObject<{ connectionId: ZodString; color: ZodString; browser: ZodString; connectedAt: ZodISODateTime; displayName: ZodNullable<ZodString>; avatarUrl: ZodNullable<...>; }, $strip>' does not satisfy the expected type 'ZodType<ViewerInfo, unknown, $ZodTypeInternals<ViewerInfo, unknown>>'. The types of '_zod.output.displayName' are incompatible between these types. Type 'string | null' is not assignable to type 'string'. Type 'null' is not assignable to type 'string'.
23 const checked = ViewerInfoSchema satisfies z.ZodType<ViewerInfo>; ~~~~~~~~~
errors/l04_schemas.ts:27:22 - error TS18048: 'result.data' is possibly 'undefined'.
27 console.log(checked, result.data.color); ~~~~~~~~~~~
Found 2 errors in the same file, starting at: errors/l04_schemas.ts:23> node errors/l04_schemas.tserrors/l04_schemas.ts:27console.log(checked, result.data.color); ^
TypeError: Cannot read properties of undefined (reading 'color')
Node.js v24.21.0TS1360 est satisfies qui refuse l’interface de GA, avec le chemin de propriété _zod.output.displayName qui explique pourquoi. TS18048 est le résultat de safeParse, une union d’un succès et d’un échec, lu sans vérifier success ; Node.js montre ce que la vérification empêche.
En C#, les types existent à l’exécution, et depuis .NET 9 System.Text.Json peut imposer deux des règles que TypeScript laisse à un schéma, les annotations nullables et les paramètres de constructeur obligatoires :
// Les types C# existent à l'exécution : System.Text.Json peut vérifier la charge utile par rapport au record dans lequel il désérialise// Les applications basées sur un fichier sont prêtes pour l'AOT par défaut, ce qui désactive le JSON par réflexion#:property PublishAot=falseusing System.Text.Json;
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web){ RespectNullableAnnotations = true, // .NET 9 : null pour une propriété non nullable est une erreur RespectRequiredConstructorParameters = true, // .NET 9 : un paramètre de constructeur manquant est une erreur};
string[] messages =[ """{"id":"policy-7","name":"Alignment policy"}""", """{"nodeId":"policy-7","health":{"resilienceScore":0.4}}""", """{"id":"policy-7","name":null}""",];foreach (var message in messages){ try { var node = JsonSerializer.Deserialize<GovernanceNode>(message, options); Console.WriteLine($"ok: {node}"); } catch (JsonException e) { Console.WriteLine($"JsonException: {e.Message}"); }}
record GovernanceNode(string Id, string Name);> dotnet run l04_json_required.csok: GovernanceNode { Id = policy-7, Name = Alignment policy }JsonException: JSON deserialization for type 'GovernanceNode' was missing required properties including: 'id', 'name'.JsonException: The constructor parameter 'Name' on type 'GovernanceNode' doesn't allow null values. Consider updating its nullability annotation. Path: $.name | LineNumber: 0 | BytePositionInLine: 28.Les deux options sont désactivées par défaut, pour la compatibilité, et les vérifications d’intervalle ou de format demandent encore du code de validation ou des attributs. Jackson, en Java, est dans la même situation, avec Bean Validation pour les règles.
Zod, Valibot ou ArkType
Section intitulée « Zod, Valibot ou ArkType »Trois bibliothèques dominent en 2026, et chacune est construite différemment :
- Zod a une API à chaînage de méthodes,
z.string().min(1), et le plus grand écosystème : tRPC, React Hook Form, les générateurs OpenAPI et les SDK d’IA l’acceptent. Sa variante fonctionnelle,zod/mini, écritz.string().check(z.minLength(1))et est conçue pour la taille du bundle. - Valibot est fonctionnelle dès le départ,
v.pipe(v.string(), v.minLength(1)): chaque vérification est une fonction, et un bundler ne garde que celles qui sont utilisées. - ArkType écrit les définitions sous forme de chaînes dans la syntaxe de TypeScript lui-même,
'string > 0', analysées par des template literal types à la compilation et compilées en validateur à l’exécution.
Le même schéma de nœud, mis en bundle pour un navigateur avec esbuild 0.28.2, minifié, avec chaque bibliothèque :
library minified gzipzod-named 442.7 kB 90.6 kBzod 87.4 kB 25.5 kBzod-mini 17.6 kB 6.1 kBvalibot 4.8 kB 1.7 kBarktype 150.1 kB 46.0 kB| Zod 4.6.5 | zod/mini |
Valibot 1.5.0 | ArkType 2.2.3 | |
|---|---|---|---|---|
| Bundle, gzip | 25,5 kB | 6,1 kB | 1,7 kB | 46,0 kB |
| API | méthodes | fonctions | fonctions | syntaxe TypeScript dans des chaînes |
| Téléchargements npm, semaine du 7 sept. 2026 | 211 005 391, avec zod/mini |
13 793 124 | 1 431 002 | |
| JSON Schema | intégré | intégré | @valibot/to-json-schema |
intégré |
| Standard Schema | oui | oui | oui | oui |
La première ligne de la mesure est une découverte : import { z } from 'zod' donne un bundle cinq fois plus gros que import * as z from 'zod', 90,6 kB contre 25,5 kB une fois compressé avec gzip, avec le même code par ailleurs (compare bundle/zod-named.ts et bundle/zod.ts). L’export nommé est un objet qui contient toutes les fonctions, donc esbuild ne peut pas retirer celles qui ne servent pas ; l’import d’espace de noms le lui permet. La documentation de Zod écrit import * as z from "zod", et c’est la forme à garder.
Le cours utilise Zod, pour trois raisons : c’est ce qu’un lecteur rencontrera dans d’autres bases de code, ses messages d’erreur et sa sortie JSON Schema ne demandent aucun package supplémentaire, et sur un serveur ou dans Node.js la taille du bundle n’a pas d’importance. Pour un bundle de navigateur où chaque kilo-octet compte, zod/mini ou Valibot sont de meilleurs choix, et la section suivante montre que ce choix n’a pas à se propager dans le code qui utilise les schémas.
Standard Schema
Section intitulée « Standard Schema »Standard Schema est une petite interface, publiée sous forme du package de types seuls @standard-schema/spec, que les trois bibliothèques implémentent : chaque schéma a une propriété ~standard avec une fonction validate et les types d’entrée et de sortie. Une bibliothèque qui accepte des schémas, une bibliothèque de formulaires ou un routeur, s’écrit une seule fois face à l’interface :
// Standard Schema : une interface que Zod, Valibot et ArkType implémentent tous, pour qu'une fonction puisse accepter n'importe lequelimport type { StandardSchemaV1 } from '@standard-schema/spec';import { type } from 'arktype';import * as v from 'valibot';import * as z from 'zod';import type { Equal, Expect } from './type-tests.ts';
type Parsed<T> = { ok: true; value: T } | { ok: false; issues: string[] };
// Écrite une seule fois, face à l'interface : le type de sortie vient du schéma, quelle que soit la bibliothèque qui l'a construitasync function parseJson<S extends StandardSchemaV1>(schema: S, text: string): Promise<Parsed<StandardSchemaV1.InferOutput<S>>> { let json: unknown; try { json = JSON.parse(text); } catch (err) { return { ok: false, issues: [`not JSON: ${err instanceof Error ? err.message : String(err)}`] }; } const result = await schema['~standard'].validate(json); if (result.issues) { return { ok: false, issues: result.issues.map((issue) => `${issue.path?.map((p) => (typeof p === 'object' ? p.key : p)).join('.') ?? ''}: ${issue.message}`) }; } return { ok: true, value: result.value };}
// Le même type de charge utile, trois bibliothèquesconst zodSchema = z.object({ target: z.string().min(1), timestamp: z.iso.datetime() });const valibotSchema = v.object({ target: v.pipe(v.string(), v.minLength(1)), timestamp: v.pipe(v.string(), v.isoTimestamp()) });const arktypeSchema = type({ target: 'string > 0', timestamp: 'string.date.iso' });
type NavigateToPlanet = { target: string; timestamp: string };type _1 = Expect<Equal<StandardSchemaV1.InferOutput<typeof zodSchema>, NavigateToPlanet>>;type _2 = Expect<Equal<StandardSchemaV1.InferOutput<typeof valibotSchema>, NavigateToPlanet>>;type _3 = Expect<Equal<StandardSchemaV1.InferOutput<typeof arktypeSchema>, NavigateToPlanet>>;
const messages = ['{"target":"saturn","timestamp":"2026-09-15T12:00:00Z"}', '{"target":"","timestamp":"yesterday"}', '{"target":'];for (const [name, schema] of [['zod', zodSchema], ['valibot', valibotSchema], ['arktype', arktypeSchema]] as const) { for (const message of messages) { const parsed = await parseJson(schema, message); console.log(`${name.padEnd(8)} ${parsed.ok ? `ok: ${parsed.value.target}` : parsed.issues.join(' | ')}`); }}zod ok: saturnzod target: Too small: expected string to have >=1 characters | timestamp: Invalid ISO datetimezod not JSON: Unexpected end of JSON inputvalibot ok: saturnvalibot target: Invalid length: Expected >=1 but received 0 | timestamp: Invalid timestamp: Received "yesterday"valibot not JSON: Unexpected end of JSON inputarktype ok: saturnarktype target: target must be non-empty | timestamp: timestamp must be an ISO 8601 (YYYY-MM-DDTHH:mm:ss.sssZ) date (was "yesterday")arktype not JSON: Unexpected end of JSON inputparseJson ne sait rien de Zod, de Valibot ni d’ArkType, et son type de retour est pourtant le type de sortie du schéma qu’elle reçoit, quel qu’il soit, grâce à StandardSchemaV1.InferOutput ; les trois tests de types vérifient que les trois bibliothèques infèrent le même type. validate peut renvoyer une Promise, pour les schémas aux vérifications asynchrones, c’est pourquoi la fonction est async. Les messages diffèrent : chaque bibliothèque a sa propre formulation, et les échecs de JSON.parse arrivent avant tout schéma.
Erreurs typées
Section intitulée « Erreurs typées »Une vérification qui échoue est un échec attendu : le programme doit le traiter, pas planter. TypeScript n’a pas d’exceptions vérifiées, et un catch reçoit unknown sous strict (useUnknownInCatchVariables), donc le type de ce qu’une fonction lève ne fait pas partie de sa signature. Un type résultat place plutôt les échecs attendus dans le type de retour :
import * as z from 'zod';import { show } from './show.ts';
// Un résultat : succès ou échec, distingués par ok, avec un type pour chaque côtétype Result<T, E> = { ok: true; value: T } | { ok: false; error: E };const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
// Les échecs dont un appelant peut faire quelque chose, sous forme d'union discriminéetype LoadError = | { kind: 'network'; cause: unknown } | { kind: 'http'; status: number } | { kind: 'invalid-json'; message: string } | { kind: 'invalid-data'; issues: string[] };
const GraphSchema = z.object({ nodes: z.array(z.object({ id: z.string(), name: z.string() })), timestamp: z.iso.datetime(),});type GovernanceGraph = z.infer<typeof GraphSchema>;
// Un faux fetch, pour que l'exemple s'exécute sans serveur : chaque URL répond différemmenttype Fetch = (url: string) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>;const fakeFetch: Fetch = async (url) => { if (url.endsWith('/offline')) throw new TypeError('fetch failed'); const bodies: Record<string, [number, string]> = { '/api/governance': [200, '{"nodes":[{"id":"policy-7","name":"Alignment policy"}],"timestamp":"2026-09-15T12:00:00Z"}'], '/api/governance/stale': [200, '{"nodes":[{"id":"policy-7"}],"timestamp":"yesterday"}'], '/api/governance/html': [200, '<!doctype html>'], '/api/governance/missing': [404, 'Not Found'], }; const [status, body] = bodies[url] ?? [500, '']; return { ok: status < 400, status, text: async () => body };};
// Chaque échec attendu est renvoyé ; un bug dans cette fonction lèverait toujours une exceptionasync function loadGraph(fetch: Fetch, url: string): Promise<Result<GovernanceGraph, LoadError>> { let response; try { response = await fetch(url); } catch (cause) { return err({ kind: 'network', cause }); } if (!response.ok) return err({ kind: 'http', status: response.status }); let json: unknown; try { json = JSON.parse(await response.text()); } catch (e) { return err({ kind: 'invalid-json', message: e instanceof Error ? e.message : String(e) }); } const parsed = GraphSchema.safeParse(json); if (!parsed.success) return err({ kind: 'invalid-data', issues: parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`) }); return ok(parsed.data);}
// L'appelant doit regarder ok avant d'atteindre la valeur, et un switch sur kind couvre chaque échecfunction describe(result: Result<GovernanceGraph, LoadError>): string { if (result.ok) return `${result.value.nodes.length} node(s) at ${result.value.timestamp}`; const error = result.error; switch (error.kind) { case 'network': return `offline (${error.cause instanceof Error ? error.cause.message : 'unknown cause'}), keeping the static graph`; case 'http': return `HTTP ${error.status}, keeping the static graph`; case 'invalid-json': return `not JSON: ${error.message}`; case 'invalid-data': return `unexpected graph: ${error.issues.join('; ')}`; default: return error satisfies never; }}
for (const url of ['/api/governance', '/api/governance/offline', '/api/governance/missing', '/api/governance/html', '/api/governance/stale']) { console.log(`${url.padEnd(26)} ${describe(await loadGraph(fakeFetch, url))}`);}
// Là où une exception reste le bon outil : une erreur que l'appelant ne peut pas traiter, avec l'erreur d'origine comme causefunction requireGraph(result: Result<GovernanceGraph, LoadError>): GovernanceGraph { if (result.ok) return result.value; throw new Error(`governance graph unavailable: ${result.error.kind}`, { cause: result.error });}try { requireGraph(await loadGraph(fakeFetch, '/api/governance/missing'));} catch (e) { show('caught', e instanceof Error ? [e.message, e.cause] : e);}/api/governance 1 node(s) at 2026-09-15T12:00:00Z/api/governance/offline offline (fetch failed), keeping the static graph/api/governance/missing HTTP 404, keeping the static graph/api/governance/html not JSON: Unexpected token '<', "<!doctype html>" is not valid JSON/api/governance/stale unexpected graph: nodes.0.name: Invalid input: expected string, received undefined; timestamp: Invalid ISO datetimecaught [ 'governance graph unavailable: http', { kind: 'http', status: 404 } ]Result<T, E>est une union discriminée surok.okrenvoieResult<T, never>eterrrenvoieResult<never, E>, et les deux sont assignables àResult<GovernanceGraph, LoadError>par la covariance de la leçon 2.LoadErrorliste les échecs dont un appelant peut faire quelque chose, chacun avec ses données : le statut d’une erreur HTTP, les problèmes de données invalides.error satisfies neverdans la branchedefaultest la vérification d’exhaustivité de la leçon 3, sans fonction utilitaire.loadGraphlève toujours une exception pour les bugs. Seuls les échecs attendus sont interceptés et renvoyés ; uneTypeErrorà l’intérieur de la fonction elle-même se propagerait, comme il se doit.- Les exceptions restent l’outil des échecs que personne ne peut traiter, et l’option
caused’Errorgarde l’erreur d’origine attachée.
Les erreurs :
> npx tsc -p out/tsconfig.l04_result.json --prettyerrors/l04_result.ts:7:20 - error TS2339: Property 'value' does not exist on type 'Result<{ nodes: string[]; }, LoadError>'. Property 'value' does not exist on type '{ ok: false; error: LoadError; }'.
7 console.log(result.value.nodes); // the value exists only when ok is true ~~~~~
errors/l04_result.ts:16:7 - error TS2322: Type '{ kind: "invalid-json"; message: string; }' is not assignable to type 'string'.
16 return error satisfies never; // invalid-json is not handled ~~~~~~
errors/l04_result.ts:16:20 - error TS1360: Type '{ kind: "invalid-json"; message: string; }' does not satisfy the expected type 'never'.
16 return error satisfies never; // invalid-json is not handled ~~~~~~~~~
errors/l04_result.ts:23:15 - error TS18046: 'e' is of type 'unknown'.
23 console.log(e.message, describe({ kind: 'http', status: 500 })); ~
Found 4 errors in the same file, starting at: errors/l04_result.ts:7TS2339 lit value sans vérifier ok. Les deux erreurs de la ligne 16 sont le cas 'invalid-json' manquant : satisfies never le signale, et le type de retour aussi. TS18046 est une variable de catch utilisée comme une Error.
La réponse de Java se trouve aussi dans la signature, avec les exceptions vérifiées :
// Les exceptions vérifiées de Java font partie de la signature : l'appelant doit les intercepter ou les déclarerimport java.io.IOException;
public class L04Checked { static String loadGraph(String url) throws IOException { throw new IOException("fetch failed: " + url); }
public static void main(String[] args) { System.out.println(loadGraph("/api/governance")); }}> javac L04Checked.javaL04Checked.java:11: error: unreported exception IOException; must be caught or declared to be thrown System.out.println(loadGraph("/api/governance")); ^1 error| C# | Java | TypeScript | |
|---|---|---|---|
| Échec attendu dans la signature | non ; un type résultat par convention, ou bool TryParse(…, out T) |
exceptions vérifiées | une union résultat |
| Type d’une valeur interceptée | Exception ou une sous-classe |
Throwable ou une sous-classe |
unknown |
| Exhaustivité sur les échecs | avertissement CS8509 de l’expression switch |
non | never |
| Chaîner une cause | innerException |
cause |
{ cause } depuis ES2022 |
À retenir
Section intitulée « À retenir »JSON.parse,response.json()et leonde SignalR renvoientany: un type écrit à cet endroit est une affirmation, etastransforme une affirmation fausse en bug silencieux, comme la mise à jourNodeChangedde GA.- Un schéma vérifie les données à l’exécution et donne le type à la compilation à partir d’une seule définition ;
satisfies z.ZodType<T>garde un schéma et une interface écrite à la main alignés. - Un schéma est un parseur : ses types d’entrée et de sortie diffèrent dès qu’il transforme ou convertit.
- Zod est le choix par défaut pour son écosystème ; Valibot et
zod/minisont beaucoup plus petits dans un navigateur ; importe Zod comme espace de noms, sinon le bundle grossit cinq fois. - Standard Schema permet à du code d’accepter un schéma de n’importe laquelle des trois bibliothèques, avec le type inféré.
- Renvoie les échecs attendus sous forme d’union résultat avec une erreur typée, et garde les exceptions pour les bugs et les échecs que personne ne peut traiter.
Exercices
Section intitulée « Exercices »- Le
GovernanceHealthStatusde GA a cinq valeurs, etForceRadiant.tsxcompare aussi le statut à'ok'et'critical'. Écris trois schémas pour un nœud : un qui rejette un statut inconnu, un qui le remplace par'unknown', et un qui le remplace et enregistre la valeur remplacée.
Solution
solutions/l04_ex1_health_status.ts :
import * as z from 'zod';
// Les cinq statuts du GovernanceHealthStatus de GA ; ForceRadiant.tsx compare aussi avec 'ok' et 'critical'const HealthStatus = z.enum(['error', 'warning', 'healthy', 'unknown', 'contradictory']);
// Strict : un statut inconnu rejette le nœud entierconst StrictNode = z.object({ id: z.string(), healthStatus: HealthStatus });// Tolérant : un statut inconnu devient 'unknown', et le reste du nœud est gardéconst TolerantNode = z.object({ id: z.string(), healthStatus: HealthStatus.catch('unknown') });
// Tolérant et visible : la valeur remplacée est signalée, pour qu'un nouveau statut sur le serveur ne passe pas inaperçuconst replaced: string[] = [];const ReportingNode = z.object({ id: z.string(), healthStatus: HealthStatus.catch((ctx) => { replaced.push(String(ctx.value)); return 'unknown'; }),});
const nodes = JSON.parse('[{"id":"policy-7","healthStatus":"healthy"},{"id":"policy-8","healthStatus":"critical"},{"id":"policy-9","healthStatus":"ok"}]');console.log('strict: ', z.array(StrictNode).safeParse(nodes).error?.issues.map((i) => `${i.path.join('.')}: ${i.message}`));console.log('tolerant:', z.array(TolerantNode).parse(nodes).map((n) => n.healthStatus));console.log('reported:', z.array(ReportingNode).parse(nodes).map((n) => n.healthStatus), 'replaced', replaced);strict: [ '1.healthStatus: Invalid option: expected one of "error"|"warning"|"healthy"|"unknown"|"contradictory"', '2.healthStatus: Invalid option: expected one of "error"|"warning"|"healthy"|"unknown"|"contradictory"']tolerant: [ 'healthy', 'unknown', 'unknown' ]reported: [ 'healthy', 'unknown', 'unknown' ] replaced [ 'critical', 'ok' ]Le schéma strict rejette le tableau entier pour une seule valeur inconnue, ce qui est juste pour une commande et dur pour un affichage. .catch garde le reste du nœud, et sa forme fonction reçoit la valeur invalide, donc un nouveau statut sur le serveur apparaît dans un journal au lieu de disparaître.
- Réécris le hub typé de la leçon 1 pour que la table contienne des schémas au lieu de types :
ondérive le type de la charge utile du schéma, valide chaque message, et appelle un callbackonInvalidavec les problèmes au lieu du handler. Accepte n’importe quel Standard Schema.
Solution
solutions/l04_ex2_validated_hub.ts :
import type { StandardSchemaV1 } from '@standard-schema/spec';import * as z from 'zod';
interface HubConnection { on(methodName: string, newMethod: (...args: any[]) => any): void;}
// La table de la leçon 1 contient maintenant des schémas, et les types des charges utiles en sont dérivésconst governanceHubEvents = { NavigateToPlanet: z.object({ target: z.string().min(1), timestamp: z.iso.datetime() }), NodeChanged: z.object({ nodeId: z.string().min(1), healthStatus: z.string(), color: z.string() }),} satisfies Record<string, StandardSchemaV1>;type Events = typeof governanceHubEvents;type Payload<K extends keyof Events> = StandardSchemaV1.InferOutput<Events[K]>;
// Le handler ne reçoit que des données validées ; un message invalide va à onInvalid, avec les problèmesfunction on<K extends keyof Events & string>(connection: HubConnection, name: K, handler: (data: Payload<K>) => void, onInvalid: (name: string, issues: readonly StandardSchemaV1.Issue[]) => void): void { const schema: StandardSchemaV1<unknown, Payload<K>> = governanceHubEvents[name]; connection.on(name, (data: unknown) => { const result = schema['~standard'].validate(data); if (result instanceof Promise) throw new TypeError(`${name}: asynchronous schemas are not supported here`); if (result.issues) onInvalid(name, result.issues); else handler(result.value); });}
const handlers = new Map<string, (data: unknown) => void>();const connection: HubConnection = { on: (name, handler) => void handlers.set(name, handler) };const reportInvalid = (name: string, issues: readonly StandardSchemaV1.Issue[]) => console.log(`invalid ${name}: ${issues.map((i) => i.message).join('; ')}`);
on(connection, 'NavigateToPlanet', (data) => console.log(`navigate to ${data.target}`), reportInvalid);on(connection, 'NodeChanged', (data) => console.log(`node ${data.nodeId} is ${data.healthStatus}`), reportInvalid);
handlers.get('NavigateToPlanet')?.({ target: 'saturn', timestamp: '2026-09-15T12:00:00Z' });handlers.get('NavigateToPlanet')?.({ planet: 'saturn' });handlers.get('NodeChanged')?.({ nodeId: 'policy-7', healthStatus: 'warning', color: '#FFB300' });navigate to saturninvalid NavigateToPlanet: Invalid input: expected string, received undefined; Invalid input: expected string, received undefinednode policy-7 is warningLa table est vérifiée avec satisfies Record<string, StandardSchemaV1>, et Payload<K> est la sortie inférée de chaque schéma. Le client n’attend pas un handler, donc un schéma asynchrone est refusé avec une erreur claire plutôt que laissé en cours d’exécution en arrière-plan. Le schéma de NodeChanged décrit ce que le hub envoie vraiment : le handler reçoit nodeId, et n’a aucune raison d’affirmer un nœud.
- Écris
map,andThenetallpourResult.allprend un tuple de résultats et renvoie un résultat de tuple, dont le type d’erreur est l’union des types d’erreur.
Solution
solutions/l04_ex3_result_helpers.ts :
import type { Equal, Expect } from '../examples/type-tests.ts';
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
const map = <T, U, E>(result: Result<T, E>, f: (value: T) => U): Result<U, E> => (result.ok ? ok(f(result.value)) : result);const andThen = <T, U, E, F>(result: Result<T, E>, f: (value: T) => Result<U, F>): Result<U, E | F> => (result.ok ? f(result.value) : result);
// all : un tuple de résultats devient un résultat de tuple, et le type d'erreur est l'union des erreurstype Values<R extends readonly Result<unknown, unknown>[]> = { -readonly [K in keyof R]: R[K] extends Result<infer T, unknown> ? T : never };type Errors<R extends readonly Result<unknown, unknown>[]> = R[number] extends infer U ? (U extends { ok: false; error: infer E } ? E : never) : never;function all<const R extends readonly Result<unknown, unknown>[]>(results: R): Result<Values<R>, Errors<R>> { const values: unknown[] = []; for (const result of results) { if (!result.ok) return result as Result<never, Errors<R>>; values.push(result.value); } return ok(values as Values<R>); // une assertion : une valeur par résultat, dans l'ordre}
type FretError = { kind: 'fret-out-of-range'; fret: number };type NoteError = { kind: 'unknown-note'; text: string };const parseFret = (text: string): Result<number, FretError> => { const fret = Number(text); return Number.isInteger(fret) && fret >= 0 && fret <= 24 ? ok(fret) : err({ kind: 'fret-out-of-range', fret });};const parseNote = (text: string): Result<string, NoteError> => (/^[A-G][#b]?$/.test(text) ? ok(text) : err({ kind: 'unknown-note', text }));
const position = all([parseNote('E'), parseFret('7')]);type _1 = Expect<Equal<typeof position, Result<[string, number], NoteError | FretError>>>;console.log(map(position, ([note, fret]) => `${note} string, fret ${fret}`));console.log(all([parseNote('H'), parseFret('7')]));console.log(andThen(parseFret('30'), (fret) => parseNote(fret > 12 ? 'E' : 'A')));{ ok: true, value: 'E string, fret 7' }{ ok: false, error: { kind: 'unknown-note', text: 'H' } }{ ok: false, error: { kind: 'fret-out-of-range', fret: 30 } }andThen élargit le type d’erreur à E | F, puisque l’une ou l’autre étape peut échouer. Dans all, le paramètre de type const garde l’argument sous forme de tuple, Values est un type mappé homomorphe sur ce tuple qui retire readonly et extrait le type de chaque valeur avec infer, et Errors se distribue sur les éléments pour rassembler les types d’erreur ; le test de types vérifie Result<[string, number], NoteError | FretError>. La boucle ne peut pas prouver qu’elle pousse une valeur par élément, donc le résultat est affirmé, comme dans le fold de la leçon 3.
- Handbook TypeScript — Narrowing, TSConfig —
useUnknownInCatchVariables - ECMAScript —
JSON.parse, InstallErrorCause - Documentation de Zod, guides de Valibot, documentation d’ArkType, Standard Schema
- API d’esbuild, et les nombres de téléchargements npm d’api.npmjs.org
- Protocole de hub SignalR et le
HubConnection.tsdu client TypeScript, ASP.NET Core v10.0.11 - Microsoft — Configuration de SignalR, Hubs fortement typés, Respecter les annotations nullables, Applications basées sur un fichier
- Java Language Specification, chapitre 11 — Exceptions