JSON a TypeScript: interfaces, type guards y los límites de la generación
En resumen: Generar una interface a partir de un JSON de muestra es solo la mitad del trabajo: los campos opcionales, null, las uniones, las fechas y los enteros grandes necesitan una pasada humana, y los tipos no hacen ninguna validación en tiempo de ejecución.

En resumen: genera la interface para ahorrar tecleo, después revisa de nuevo la opcionalidad y el null, mantén las fechas y los enteros grandes como cadenas y añade validación en tiempo de ejecución en el límite; tratar los tipos generados como el contrato no te da ninguna validación.
Un generador solo ve la única muestra que le pegas. Si los campos pueden faltar, si los valores pueden ser null y qué formato usan las fechas son todas suposiciones, y las suposiciones equivocadas son tuyas de corregir.
1. Qué hace realmente el generador
Pega una muestra JSON en JSON a TypeScript y la interface que obtienes es un resumen de forma: cuenta las claves y los tipos de valor que ha visto y después elige la representación más estrecha disponible.
{
"id": 1,
"email": "ada@example.com",
"profile": { "nickname": "Ada", "age": 36 },
"tags": ["admin", "beta"],
"createdAt": "2026-10-31T08:00:00Z"
}
La salida se parece más o menos a esto:
interface User {
id: number;
email: string;
profile: { nickname: string; age: number };
tags: string[];
createdAt: string;
}
Compila, pero describe un valor ideal, no el contrato real del endpoint. El generador no sabe si email puede faltar, si tags puede estar vacío o si createdAt siempre lleva zona horaria. Ni siquiera sabe que id podría ser una cadena en otros registros.
Así que la expectativa correcta es: el generador ahorra tecleo, la persona aporta el significado.
2. Campos opcionales y null: ? no es | null
Esta es la parte de la salida generada que más se copia y la que más se rompe.
| Forma en TypeScript | Significado | JSON correspondiente |
|---|---|---|
email: string |
Siempre presente, siempre una cadena | {"email":"a@b.c"} |
email?: string |
La clave puede estar ausente; cuando está presente no puede ser null | {} o {"email":"a@b.c"} |
email: string | null |
La clave siempre está presente, el valor puede ser null | {"email":null} |
email?: string | null |
La clave puede estar ausente y el valor puede ser null | Los tres casos |
El generador ha visto una sola muestra, así que normalmente produce la primera forma. Si la API puede devolver realmente null, el tipo está mintiendo: todo compila y después email.toLowerCase() lanza en tiempo de ejecución.
Hay dos formas de corregirlo:
- Recoge varias muestras reales que cubran tanto "clave ausente" como "valor null" y deja que el generador vuelva a inferir.
- Tras generar, cambia los campos a mano a
?: T | nully trata de verdad ambas ramas en el código.
No tomes el atajo de marcar todo como ?: string. Opcional significa "la clave puede no existir", no "el valor puede estar vacío". undefined y null también se comportan distinto en JSON.stringify: el primero se omite, el segundo se conserva como null.
3. Elementos de array heterogéneos y tipos unión
Los arrays son la estructura más fácil de equivocar. Si tags aparece como ["admin"], [] y ["beta", 3] en tres registros, el generador puede emitir string[], (string | number)[] o unknown[], según las muestras que haya visto.
Una persona tiene que decidir:
- Los tipos de los elementos realmente están mezclados: escribe
(string | number)[]y estrecha el tipo en el punto de uso. - Solo un registro está sucio: corrige los datos. No lo ocultes con
any[]. - Arrays vacíos: el generador no puede inferir un tipo de elemento y recurre a
never[]oany[], así que debes rellenarlo. - Tuplas de longitud fija: usa
[string, string]solo cuando el negocio garantice de verdad la longitud; de lo contrario, añadir un elemento más tarde se convierte en un error de compilación.
Los arrays de objetos también requieren atención cuando las claves son desiguales:
interface OrderItem {
sku: string;
quantity: number;
discount?: number; // algunos registros no llevan este campo
}
Pon la opcionalidad en el tipo del elemento, no en el array. items?: OrderItem[] significa que el array en sí puede estar ausente, que es una afirmación completamente distinta.
4. Fechas, enteros grandes, enumeraciones: tipos que JSON no tiene
JSON solo tiene cadenas y números; el significado de negocio vive en las convenciones. El generador infiere a partir de literales, así que corrígelo a mano:
| Literal JSON | Salida generada habitual | Lo que debería ser |
|---|---|---|
"2026-10-31T08:00:00Z" |
string |
Mantén string en la capa de transporte y convierte en el punto de uso; nunca declares Date directamente |
1717075200000 |
number |
Usa number para una marca de tiempo en milisegundos y documenta la unidad |
"1234567890123456789" |
string |
Mantenla como string para que number no pierda precisión |
1 y "1" mezclados |
number | string |
Normaliza a una sola forma; no empujes la unión al código de negocio |
"admin" |
string |
Una unión literal 'admin' | 'editor', o una constante de enumeración compartida |
true y "true" mezclados |
boolean | string |
Corrige la fuente de datos en lugar de generar este tipo |
La regla más importante: las fechas en JSON siempre son cadenas. Escribir createdAt: Date es un autoengaño habitual: JSON.parse nunca construye un Date, así que el valor en tiempo de ejecución sigue siendo una cadena y .getTime() lanza. Mantén el tipo de transporte como string, llama explícitamente a new Date(value) en el límite y comprueba Number.isNaN.
Los enteros grandes funcionan igual. Cuando un id supera 2^53, JSON.parse ya ha perdido precisión, y generar number solo consolida el error en el tipo. El backend devuelve una cadena, el frontend la mantiene como cadena: esa es la única opción fiable.
5. Los tipos generados no hacen validación en tiempo de ejecución
Los tipos de TypeScript se borran en tiempo de compilación. Por muy precisa que sea la interface, no puede detener una respuesta de {"id": "x"}: restringe tu propio código, no los bytes que llegan por la red.
/**
* Comprueba si un valor en tiempo de ejecución es un objeto de usuario.
* @param value El valor en tiempo de ejecución que se va a validar
* @returns Si satisface la forma mínima de User
*/
function isUser(value: unknown): value is User {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return typeof candidate.id === 'number' && typeof candidate.email === 'string';
}
Un type guard solo cubre los campos que escribes en la comprobación. A medida que se multiplican los campos, los guards escritos a mano se convierten en una segunda definición que mantener, que se va alejando de la interface.
El compromiso práctico es así:
| Enfoque | Validación en tiempo de ejecución | Origen de los tipos | Encaja en |
|---|---|---|---|
| Solo interface | Ninguna | Escritos a mano o generados | Datos internos de confianza, restricciones en tiempo de compilación |
| Interface más guard escrito a mano | Sí | Dos definiciones que se desvían | Pocos campos, límite claro |
| zod / valibot | Sí | Tipos inferidos desde el schema | API externas, formularios, configuración |
| JSON Schema más validador | Sí | Tipos generados desde el schema | Contratos entre lenguajes, documentación |
Si los datos vienen de fuera, haz del schema la única fuente de verdad y deriva los tipos con z.infer o json-schema-to-ts. Entonces "la validación pasa" y "el tipo es correcto" son el mismo hecho, y no hay una segunda definición que se desvíe. Para el enfoque completo de sincronización bidireccional, consulta Sincronización entre Schema y tipos de TypeScript.
6. Interface o type: no discutas sobre la salida generada
Los generadores suelen emitir interface, pero los dos son intercambiables en la mayoría de las situaciones. Lo que realmente importa son tres capacidades:
- Fusión de declaraciones:
interfacese puede declarar varias veces y se fusiona automáticamente;typeno. Eso ayuda al parchear tipos de terceros, pero los modelos de negocio deberían evitarlo: la fusión deja que los "campos extra" pasen por el compilador sin que nadie lo note. - Uniones y tipos mapeados:
typepuede expresarA | B,Partial<T>y tipos literales de plantilla directamente;interfaceno. - Recursión: ambos lo admiten, pero las formas recursivas complejas se leen de forma más natural como
type.
Una regla de equipo más útil: usa interface para las formas de objeto, y type para uniones, tipos de utilidad y tipos de función. Deja la salida generada en su valor por defecto y gasta el tiempo de revisión en la opcionalidad y la validación en tiempo de ejecución.
7. Cadena de herramientas: tres formas de generar
| Enfoque | Entrada | Ventaja | Limitación |
|---|---|---|---|
| JSON a TypeScript del sitio | JSON de muestra | Funciona al instante, procesado localmente | Basado solo en la muestra actual; requiere estrechar a mano |
| quicktype | JSON / JSON Schema | Salida multilingüe, integración con CLI | Inferencia conservadora, las uniones pueden ser demasiado amplias |
| json-schema-to-typescript | JSON Schema | Guiado por el contrato y reproducible | Requiere un schema fiable primero |
Una invocación típica de quicktype:
npx quicktype -s json -o user.ts user.json --just-types
--just-types emite solo los tipos y ningún código de conversión en tiempo de ejecución, lo que encaja en proyectos que ya tienen una capa de análisis. Si el schema está escrito a mano o lo produce el backend, json-schema-to-typescript encaja mejor porque respeta required, enum y additionalProperties: restricciones que la inferencia por muestras sencillamente no puede ver.
La generación de schemas tiene sus propias trampas, como marcar todos los campos como obligatorios o convertir null en opcional en silencio; consulta trampas habituales de los generadores de JSON Schema.
8. Lista de comprobación antes de confirmar tipos generados
Repasa estos cinco puntos antes de confirmar la salida:
- Normaliza los nombres de campo: no dejes que
userIdyuser_idcoexistan; mapea una vez en una capa de conversión. - Revisa de nuevo la opcionalidad: compara con respuestas reales y averigua qué campos pueden faltar o ser
null. - Mantén fechas e ids como cadenas: documenta la unidad y el formato en el tipo, y analiza explícitamente en el punto de uso.
- Reduce
anyyunknown: allá donde el generador emitióany, añade un tipo o manténunknowny añade un guard. - Añade validación en tiempo de ejecución: valida los datos externos al menos una vez; no esperes que una interface atrape datos malos.
Si el proyecto usa varias bibliotecas JSON (JSON.parse, superjson, lossless-json), su comportamiento con enteros grandes y fechas difiere, así que confírmalo durante la selección. Consulta selección de bibliotecas de análisis JSON.
¿Por qué el generador marca como obligatorios todos los campos opcionales?
Vio una sola muestra, así que todas las claves que aparecieron se tratan como siempre presentes. Proporciona varias muestras que cubran casos distintos, o genera desde JSON Schema: el array required de allí es la fuente autoritativa.
¿Por qué declarar Date en una interface falla en tiempo de ejecución?
JSON.parse produce solo cadenas, números, booleanos, null, objetos y arrays; nunca construye un Date. Declarar Date es una promesa en tiempo de compilación mientras el valor en tiempo de ejecución sigue siendo una cadena. Mantén string en la capa de transporte y convierte en el límite.
¿Se pueden usar los tipos generados como validación?
No. Los tipos de TypeScript se borran tras la compilación y no imponen nada sobre los datos en tiempo de ejecución. Valida los datos externos con zod, valibot, ajv o un type guard escrito a mano.
¿Un id de entero grande debe tiparse como number o como string?
Si puede superar 2^53, debe ser string. JSON.parse pierde precisión durante el análisis, y tiparlo como number solo escribe el error en el tipo, donde ya no puede corregirse.