4. Fronteras en tiempo de ejecución — esquemas y errores tipados
Secciones
Ajustes de lectura
Ninguna voz para este idioma en este navegador.
Más voces
Código y términos técnicos en inglés
Desactivado: la voz de la página lo lee todo, sin pausa entre dos voces.
Ninguna voz para este idioma en este navegador.
Más voces
Más voces: Microsoft Edge ofrece voces naturales en línea, y Windows (Configuración › Hora e idioma › Voz), macOS y Android instalan otras.
Código: los archivos examples/l04_* y errors/l04_*, los bundles de bundle/ con bundle-size.mjs, y los equivalentes en C# y Java en compare/ (l04_hub_json.cs, l04_json_required.cs) y compare_fail/ (L04Checked.java).
Las tres primeras lecciones hicieron que el verificador demostrara más cosas. Todo lo que demuestra descansa en una suposición: que cada valor tiene el tipo que se escribió para él. Dentro del programa, tsc lo comprueba. En la frontera, donde los valores vienen de JSON.parse, response.json(), un mensaje SignalR o localStorage, nada lo comprueba: esas APIs devuelven any, o un parámetro tipado a mano, y los tipos se borran antes de que el programa se ejecute. Los desarrolladores C# y Java están acostumbrados a un deserializador que al menos construye el tipo declarado y falla cuando no puede. En TypeScript, un tipo incorrecto en la frontera compila, se ejecuta, y falla en otro sitio, o no falla en absoluto.
Una actualización perdida en GA
Sección titulada «Una actualización perdida en GA»El hub C# de GA difunde el cambio de salud de un nodo en GovernanceHub.cs, líneas 163-176, como un objeto anónimo cuya primera propiedad es nodeId. El frontend lo recibe en DataLoader.ts, líneas 276-279:
connection.on('NodeChanged', (data: { nodeId: string; health: unknown; healthStatus: string; color: string }) => { // Actualización parcial — un solo nodo onUpdate({ nodes: [data as unknown as GovernanceNode], edges: [], globalHealth: { resilienceScore: 0, lolliCount: 0, ergolCount: 0 }, timestamp: new Date().toISOString() } as GovernanceGraph);});El tipo del parámetro es correcto: coincide con el servidor. La aserción as unknown as GovernanceNode dice luego que un objeto con nodeId es un nodo, cuya clave es id. ForceRadiant.tsx, línea 3585, pasa los nodos de ese grafo a updateNodeHealth, que busca cada nodo de la escena por id entre los nodos nuevos. No encuentra nada, y la actualización se descarta sin error. Una búsqueda de código en GitHub no encuentra hoy ningún llamador de BroadcastNodeChanged, así que el camino está latente (por verificar en un servidor en marcha); es exactamente el tipo de bug que aparece el día en que alguien lo llama.
El ejemplo reduce el manejador y la función de actualización a lo esencial:
// El manejador NodeChanged de GuitarAlchemist/ga, reducido: se afirma que la carga útil es un nodo, y la actualización se pierdeimport * 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: busca cada nodo existente por id entre los nodos nuevos, y actualiza su saludfunction 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 } }];
// El mensaje que envía GovernanceHub.BroadcastNodeChanged, tal como lo serializa SignalR (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, líneas 276-279: el parámetro del manejador se anota, y luego se afirma que es 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 esquema comprueba la misma carga útil en la frontera, y dice qué está malconst 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));
// El esquema de lo que envía de verdad el hub, y la conversión a nodo escrita una sola vezconst 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.4Con la aserción, updated está vacío y la puntuación se queda en 0.9. El primer esquema, GovernanceNodeSchema, es el tipo que suponía el código, y dice de inmediato qué está mal en el mensaje: no hay id, no hay name. El segundo esquema describe lo que envía el hub, y la conversión de un mensaje a un nodo se escribe una sola vez, tras la comprobación; la actualización llega a su destino.
Qué envía exactamente el hub es una pregunta para el lado C#, y un programa C# la responde con el propio serializador de SignalR:
// Lo que GovernanceHub.cs pone en la red: el protocolo de hub JSON de SignalR, con sus opciones de serialización por defecto#:sdk Microsoft.NET.Sdk.Web// Las aplicaciones basadas en archivo están preparadas para AOT por defecto, lo que desactiva el JSON basado en reflexión: los tipos anónimos lo necesitan#:property PublishAot=falseusing System.Buffers;using System.Text;using Microsoft.AspNetCore.SignalR.Protocol;
var protocol = new JsonHubProtocol();
// BroadcastNodeChanged envía un objeto anónimo cuya primera propiedad es nodeId, no 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 envía records ViewerInfo: las propiedades PascalCase pasan a camelCase, y null se escribe como 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); // Cada mensaje termina con el carácter separador de registros, 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}]]}El protocolo de hub JSON escribe los argumentos con System.Text.Json y nombres de propiedad en camelCase por defecto. El objeto anónimo conserva nodeId. El segundo mensaje es el ViewersChanged de GA: el string? DisplayName = null del record se escribe como "displayName":null. El ViewerInfo del frontend declara displayName?: string, que, como mostró la lección 2, es un tipo distinto de string | null. El código de GA funciona porque cada lectura usa ?. o ??, que tratan igual null y undefined.
Merece la pena conocer dos detalles del programa C#. Las aplicaciones basadas en archivo se publican con AOT nativo por defecto, lo que desactiva el JSON basado en reflexión; los tipos anónimos lo necesitan, de ahí #:property PublishAot=false. Y el on del cliente JavaScript se declara como on(methodName: string, newMethod: (...args: any[]) => any): cada tipo de parámetro de un manejador es una afirmación que nada comprueba.
flowchart LR hub["Hub C#: SendAsync con un objeto"] --> wire["Texto JSON en el WebSocket"] wire --> on["connection.on: argumentos tipados any"] on -->|"as GovernanceNode"| trust["tipo supuesto: pasan claves incorrectas"] on -->|"schema.safeParse"| check{"¿válido?"} check -->|sí| typed["valor tipado"] check -->|no| issues["problemas: registrados o comunicados"]La biblioteca de componentes de GA tiene 42 llamadas a JSON.parse, 55 líneas que afirman el resultado de response.json() con as, y 34 as unknown as fuera de las pruebas, en el commit 32f143c. No todas son fronteras, y no todas son incorrectas; cada una es un sitio donde se cree en un tipo en lugar de comprobarlo.
Esquemas: una definición, dos comprobaciones
Sección titulada «Esquemas: una definición, dos comprobaciones»Una biblioteca de esquemas describe los datos con valores, comprueba contra ellos los datos desconocidos en tiempo de ejecución, y calcula el tipo TypeScript a partir de la misma descripción, con las técnicas de la lección 1. Este curso usa Zod 4:
import * as z from 'zod';import type { Equal, Expect } from './type-tests.ts';import { show } from './show.ts';
// 1. El tipo derivado del esquema: una definición, comprobada en tiempo de ejecución y en tiempo de compilaciónconst 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(), // el string? DisplayName = null del record C# se envía como null avatarUrl: z.string().nullable(),});type ViewerInfo = z.infer<typeof ViewerInfoSchema>;type _1 = Expect<Equal<ViewerInfo['displayName'], string | null>>;
// 2. O el esquema comprobado contra una interfaz escrita a mano: satisfies z.ZodType<T> falla si se desincronizan.// El ViewerInfo de GA en DataLoader.ts declara displayName?: string, donde null no encaja (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. Los tipos de entrada y de salida difieren en cuanto el esquema transforma: cadenas en el mensaje, un Date y un number en el programaconst 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. Una marca añadida por el esquema: la única forma de obtener un NodeId es pasar la comprobación (lección 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. Claves desconocidas: eliminadas por defecto, conservadas con z.looseObject, rechazadas con 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. El esquema como JSON Schema, el formato que usan los documentos OpenAPI y los generadores de C# o 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-]+$'}- Tipos derivados.
z.inferes el tipo de salida del esquema. La prueba de tipos comprueba quedisplayNameesstring | null, como lo envía el hub. - Un esquema comprobado contra una interfaz. Cuando la interfaz viene primero, por ejemplo generada a partir de un documento OpenAPI,
satisfies z.ZodType<ViewerInfoFixed>falla en cuanto los dos se desincronizan. Con el propioViewerInfode GA, falla, que es la desincronización de la sección anterior encontrada por el compilador. - Entrada y salida. Un
transformo uncoercehace que el tipo del mensaje difiera del tipo del programa:sentAtes unstringenz.inputy unDateenz.output. Un esquema es un analizador, no solo un validador. - Marcas.
.brand<'NodeId'>()añade la marca de la lección 3 al tipo de salida, así que el constructor inteligente es el propio esquema. - Claves desconocidas.
z.objectlas elimina, lo que protege el código que expande el resultado en una petición;z.strictObjectlas rechaza. - JSON Schema.
z.toJSONSchemaconvierte el esquema al formato que leen OpenAPI y los generadores de C# y Java, así que el esquema puede ser el contrato en lugar de una copia de él.
Los errores:
> 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 es satisfies rechazando la interfaz de GA, con la ruta de propiedad _zod.output.displayName que explica por qué. TS18048 es el resultado de safeParse, una unión de éxito y fallo, leído sin comprobar success; Node.js muestra lo que la comprobación evita.
En C#, los tipos existen en tiempo de ejecución, y desde .NET 9 System.Text.Json puede imponer dos de las reglas que TypeScript deja a un esquema, las anotaciones de nulabilidad y los parámetros de constructor obligatorios:
// Los tipos de C# existen en tiempo de ejecución: System.Text.Json puede comprobar la carga útil contra el record en el que deserializa// Las aplicaciones basadas en archivo están preparadas para AOT por defecto, lo que desactiva el JSON basado en reflexión#:property PublishAot=falseusing System.Text.Json;
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web){ RespectNullableAnnotations = true, // .NET 9: null en una propiedad no anulable es un error RespectRequiredConstructorParameters = true, // .NET 9: un parámetro de constructor que falta es un error};
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.Las dos opciones están desactivadas por defecto, por compatibilidad, y las comprobaciones de rango o de formato siguen necesitando código de validación o atributos. Jackson, en Java, está en la misma situación, con Bean Validation para las reglas.
Zod, Valibot o ArkType
Sección titulada «Zod, Valibot o ArkType»Tres bibliotecas dominan en 2026, y cada una está construida de forma distinta:
- Zod tiene una API de encadenamiento de métodos,
z.string().min(1), y el ecosistema más grande: tRPC, React Hook Form, los generadores OpenAPI y los SDKs de IA lo aceptan. Su variante funcional,zod/mini, escribez.string().check(z.minLength(1))y está pensada para el tamaño del bundle. - Valibot es funcional desde el principio,
v.pipe(v.string(), v.minLength(1)): cada comprobación es una función, y un bundler solo conserva las que se usan. - ArkType escribe las definiciones como cadenas con la propia sintaxis de TypeScript,
'string > 0', analizadas por tipos de plantilla literal en tiempo de compilación y compiladas en un validador en tiempo de ejecución.
El mismo esquema de nodo, empaquetado para un navegador con esbuild 0.28.2, minificado, con cada biblioteca:
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étodos | funciones | funciones | sintaxis TypeScript en cadenas |
| Descargas npm, semana del 7 de sept. de 2026 | 211,005,391, con zod/mini |
13,793,124 | 1,431,002 | |
| JSON Schema | integrado | integrado | @valibot/to-json-schema |
integrado |
| Standard Schema | sí | sí | sí | sí |
La primera línea de la medición es un hallazgo: import { z } from 'zod' da un bundle cinco veces más grande que import * as z from 'zod', 90.6 kB frente a 25.5 kB con gzip, con el mismo código en todo lo demás (compara bundle/zod-named.ts y bundle/zod.ts). La exportación con nombre es un objeto que contiene todas las funciones, así que esbuild no puede eliminar las que no se usan; la importación de espacio de nombres se lo permite. La documentación de Zod escribe import * as z from "zod", y esa es la forma que hay que mantener.
El curso usa Zod, por tres motivos: es lo que un lector encontrará en otras bases de código, sus mensajes de error y su salida JSON Schema no necesitan ningún paquete extra, y en un servidor o en Node.js el tamaño del bundle no importa. Para un bundle de navegador donde cuenta cada kilobyte, zod/mini o Valibot son mejores opciones, y la sección siguiente muestra que la elección no tiene por qué filtrarse al código que usa los esquemas.
Standard Schema
Sección titulada «Standard Schema»Standard Schema es una pequeña interfaz, publicada como el paquete de solo tipos @standard-schema/spec, que implementan las tres bibliotecas: cada esquema tiene una propiedad ~standard con una función validate y los tipos de entrada y de salida. Una biblioteca que acepta esquemas, una biblioteca de formularios o un router, se escribe contra la interfaz una sola vez:
// Standard Schema: una interfaz que implementan Zod, Valibot y ArkType, así que una función puede aceptar cualquiera de ellosimport 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[] };
// Escrita una vez, contra la interfaz: el tipo de salida viene del esquema, sea cual sea la biblioteca que lo construyóasync 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 };}
// El mismo tipo de carga útil, tres bibliotecasconst 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 no sabe nada de Zod, Valibot ni ArkType, y aun así su tipo de retorno es el tipo de salida del esquema que reciba, a través de StandardSchemaV1.InferOutput; las tres pruebas de tipos comprueban que las tres bibliotecas infieren el mismo tipo. validate puede devolver una Promise, para esquemas con comprobaciones asíncronas, por eso la función es async. Los mensajes difieren: cada biblioteca tiene su propia redacción, y los fallos de JSON.parse llegan antes que cualquier esquema.
Errores tipados
Sección titulada «Errores tipados»Una comprobación fallida es un fallo esperado: el programa debería manejarlo, no romperse. TypeScript no tiene excepciones comprobadas, y un catch recibe unknown con strict (useUnknownInCatchVariables), así que el tipo de lo que lanza una función no forma parte de su firma. Un tipo resultado pone en cambio los fallos esperados en el tipo de retorno:
import * as z from 'zod';import { show } from './show.ts';
// Un resultado: éxito o fallo, distinguidos por ok, con un tipo para cada ladotype 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 });
// Los fallos con los que un llamador puede hacer algo, como una unión discriminadatype 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 fetch falso, para que el ejemplo se ejecute sin servidor: cada URL responde de forma distintatype 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 };};
// Cada fallo esperado se devuelve; un bug en esta función seguiría lanzando una excepciónasync 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);}
// El llamador tiene que mirar ok antes de poder llegar al valor, y un switch sobre kind cubre cada fallofunction 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))}`);}
// Donde una excepción sigue siendo la herramienta adecuada: un error que el llamador no puede manejar, con el error original como causafunction 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>es una unión discriminada sobreok.okdevuelveResult<T, never>yerrdevuelveResult<never, E>, y los dos son asignables aResult<GovernanceGraph, LoadError>por la covarianza de la lección 2.LoadErrorlista los fallos con los que un llamador puede hacer algo, cada uno con sus datos: el código de estado de un error HTTP, los problemas de unos datos no válidos.error satisfies neveren la ramadefaultes la comprobación de exhaustividad de la lección 3, sin función auxiliar.loadGraphsigue lanzando excepciones para los bugs. Solo se capturan y devuelven los fallos esperados; unTypeErrordentro de la propia función se propagaría, como debe ser.- Las excepciones siguen siendo la herramienta para los fallos que nadie puede manejar, y
Error, con su opcióncause, mantiene adjunto el error original.
Los errores:
> 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 lee value sin comprobar ok. Los dos errores de la línea 16 son el caso 'invalid-json' que falta: lo señala satisfies never, y también el tipo de retorno. TS18046 es una variable de catch usada como un Error.
La respuesta de Java también está en la firma, con las excepciones comprobadas:
// Las excepciones comprobadas de Java forman parte de la firma: el llamador debe capturarlas o declararlasimport 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 | |
|---|---|---|---|
| Fallo esperado en la firma | no; un tipo resultado por convención, o bool TryParse(…, out T) |
excepciones comprobadas | una unión resultado |
| Tipo de un valor capturado | Exception o una subclase |
Throwable o una subclase |
unknown |
| Exhaustividad sobre los fallos | advertencia CS8509 de la expresión switch |
no | never |
| Encadenar una causa | innerException |
cause |
{ cause } desde ES2022 |
Puntos clave
Sección titulada «Puntos clave»JSON.parse,response.json()y elonde SignalR devuelvenany: un tipo escrito ahí es una afirmación, yasconvierte una afirmación errónea en un bug silencioso, como la actualizaciónNodeChangedde GA.- Un esquema comprueba los datos en tiempo de ejecución y da el tipo en tiempo de compilación a partir de una sola definición;
satisfies z.ZodType<T>mantiene sincronizados un esquema y una interfaz escrita a mano. - Un esquema es un analizador: sus tipos de entrada y de salida difieren en cuanto transforma o convierte.
- Zod es la opción por defecto por su ecosistema; Valibot y
zod/minison mucho más pequeños en un navegador; importa Zod como espacio de nombres, o el bundle se multiplica por cinco. - Standard Schema permite que el código acepte un esquema de cualquiera de las tres bibliotecas con el tipo inferido.
- Devuelve los fallos esperados como una unión resultado con un error tipado, y reserva las excepciones para los bugs y los fallos que nadie puede manejar.
Ejercicios
Sección titulada «Ejercicios»- El
GovernanceHealthStatusde GA tiene cinco valores, yForceRadiant.tsxtambién compara el estado con'ok'y'critical'. Escribe tres esquemas para un nodo: uno que rechace un estado desconocido, uno que lo sustituya por'unknown', y uno que lo sustituya y registre el valor que sustituyó.
Solución
solutions/l04_ex1_health_status.ts:
import * as z from 'zod';
// Los cinco estados del GovernanceHealthStatus de GA; ForceRadiant.tsx también compara con 'ok' y 'critical'const HealthStatus = z.enum(['error', 'warning', 'healthy', 'unknown', 'contradictory']);
// Estricto: un estado desconocido rechaza todo el nodoconst StrictNode = z.object({ id: z.string(), healthStatus: HealthStatus });// Tolerante: un estado desconocido pasa a ser 'unknown', y se conserva el resto del nodoconst TolerantNode = z.object({ id: z.string(), healthStatus: HealthStatus.catch('unknown') });
// Tolerante y visible: se informa del valor sustituido, para que un estado nuevo en el servidor no pase desapercibidoconst 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' ]El esquema estricto rechaza todo el array por un único valor desconocido, lo que es correcto para un comando y duro para una visualización. .catch conserva el resto del nodo, y su forma de función recibe el valor no válido, así que un estado nuevo en el servidor aparece en un log en lugar de desaparecer.
- Reescribe el hub tipado de la lección 1 para que la tabla contenga esquemas en lugar de tipos:
onderiva el tipo de la carga útil a partir del esquema, valida cada mensaje, y llama a un callbackonInvalidcon los problemas en lugar de al manejador. Acepta cualquier Standard Schema.
Solución
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 tabla de la lección 1 contiene ahora esquemas, y los tipos de las cargas útiles se derivan de ellosconst 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]>;
// El manejador solo recibe datos validados; un mensaje no válido va a onInvalid, con los problemasfunction 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 tabla se comprueba con satisfies Record<string, StandardSchemaV1>, y Payload<K> es la salida inferida de cada esquema. El cliente no espera a un manejador, así que un esquema asíncrono se rechaza con un error claro en lugar de dejarse ejecutando en segundo plano. El esquema de NodeChanged describe lo que envía de verdad el hub: el manejador recibe nodeId, y no tiene ningún motivo para afirmar que es un nodo.
- Escribe
map,andThenyallparaResult.alltoma una tupla de resultados y devuelve un resultado de una tupla, cuyo tipo de error es la unión de los tipos de error.
Solución
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: una tupla de resultados se convierte en un resultado de una tupla, y el tipo de error es la unión de los errorestype 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>); // una aserción: un valor por resultado, en orden}
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 ensancha el tipo de error a E | F, ya que cualquiera de los dos pasos puede fallar. En all, el parámetro de tipo const mantiene el argumento como tupla, Values es un tipo mapeado homomórfico sobre ella que quita readonly y extrae cada tipo de valor con infer, y Errors se distribuye sobre los elementos para reunir los tipos de error; la prueba de tipos comprueba Result<[string, number], NoteError | FretError>. El bucle no puede demostrar que añade un valor por elemento, así que el resultado se afirma, como en el fold de la lección 3.
Fuentes
Sección titulada «Fuentes»- TypeScript handbook — Narrowing, TSConfig —
useUnknownInCatchVariables - ECMAScript —
JSON.parse, InstallErrorCause - Documentación de Zod, guías de Valibot, documentación de ArkType, Standard Schema
- API de esbuild, y los recuentos de descargas de npm de api.npmjs.org
- Protocolo de hub de SignalR y el
HubConnection.tsdel cliente TypeScript, ASP.NET Core v10.0.11 - Microsoft — Configuración de SignalR, Hubs fuertemente tipados, Respetar las anotaciones de nulabilidad, Aplicaciones basadas en archivo
- Java Language Specification, capítulo 11 — Exceptions