Leer JSON con tipos en TypeScript, sin perder la seguridad

JSON.parse devuelve any, y any apaga TypeScript para todo lo que toca: puedes escribir mal una propiedad y nadie avisa. Vemos tres formas de leer un archivo de configuración en JSON sin perder la seguridad de los tipos, y cuándo conviene cada una.

El archivo

{
  "puerto": 8080,
  "modo": "produccion",
  "admins": ["ana@ejemplo.com", "luis@ejemplo.com"]
}

El problema: any

import { readFile } from "node:fs/promises";

// JSON.parse devuelve any: TypeScript deja de revisar todo lo que salga de ahí.
const config = JSON.parse(await readFile("config.json", "utf8"));
console.log(config.puerto + 1);
console.log(config.puertoo);          // errata: ningún aviso
console.log(config.admins.toUpperCase); // tampoco: es un array, no un texto
npx tsc --noEmit --strict --target esnext --module nodenext --types node --locale es any.ts
tsc(sin errores)
node any.ts
salida8081
undefined
undefined

Compila sin un aviso, pero hay dos errores: puertoo está mal escrito y admins es un array, que no tiene toUpperCase. Al ejecutar salen dos undefined silenciosos.

Forma 1: unknown y una guarda de tipo

Si guardas el resultado como unknown, TypeScript no deja usarlo hasta que lo compruebes:

interface Config {
  puerto: number;
  modo: "desarrollo" | "produccion";
  admins: string[];
}
const datos: unknown = JSON.parse('{"puerto": 8080}');
console.log(datos.puerto);
const config: Config = datos;
npx tsc --noEmit --strict --target esnext --module nodenext --types node --locale es guarda-error.ts
tscguarda-error.ts(7,13): error TS18046: "datos" es de tipo "unknown".
guarda-error.ts(8,7): error TS2322: El tipo 'unknown' no se puede asignar al tipo 'Config'.

La comprobación se escribe en una guarda de tipo: una función que devuelve x is Config. Si devuelve true, TypeScript da por hecho que es un Config:

import { readFile } from "node:fs/promises";

interface Config {
  puerto: number;
  modo: "desarrollo" | "produccion";
  admins: string[];
}

// Una guarda de tipo: si devuelve true, TypeScript da por hecho que x es Config.
function esConfig(x: unknown): x is Config {
  if (typeof x !== "object" || x === null) return false;
  const c = x as Record<string, unknown>;
  return (
    typeof c.puerto === "number" &&
    (c.modo === "desarrollo" || c.modo === "produccion") &&
    Array.isArray(c.admins) &&
    c.admins.every((a) => typeof a === "string")
  );
}

async function cargar(ruta: string): Promise<Config> {
  const datos: unknown = JSON.parse(await readFile(ruta, "utf8"));
  if (!esConfig(datos)) throw new Error(`${ruta} no tiene el formato esperado`);
  return datos; // aquí ya es Config
}

const config = await cargar("config.json");
console.log(`Puerto ${config.puerto + 1}, modo ${config.modo}, ${config.admins.length} admins`);

try {
  await cargar("malo.json");
} catch (e) {
  console.log(e instanceof Error ? e.message : e);
}
npx tsc --noEmit --strict --target esnext --module nodenext --types node --locale es guarda.ts
node guarda.ts
salidaPuerto 8081, modo produccion, 2 admins
malo.json no tiene el formato esperado

El archivo malo.json trae el puerto como texto y un modo que no existe, y se rechaza al cargarlo:

{
  "puerto": "8080",
  "modo": "pruebas"
}
La guarda es una promesa tuya. TypeScript no revisa que esConfig compruebe de verdad todos los campos: si olvidas uno, te creerá igual. Para estructuras grandes, un esquema de Zod es más seguro, porque la comprobación y el tipo salen de la misma definición.

Forma 2: importar el JSON

Si el archivo va con el código, se puede importar como un módulo. TypeScript lee el archivo y deduce su tipo. Hace falta --resolveJsonModule (o "resolveJsonModule": true en el tsconfig.json):

// Importar el JSON como módulo: TypeScript deduce el tipo leyendo el archivo.
import config from "./config.json" with { type: "json" };

console.log(config.puerto + 1, config.modo, config.admins[0]);
npx tsc --noEmit --strict --target esnext --module nodenext --types node --locale es --resolveJsonModule importar.ts
node importar.ts
salida8081 produccion ana@ejemplo.com

Ahora las erratas sí se detectan. Pero el tipo deducido es el más general: modo es string, no "desarrollo" | "produccion":

import config from "./config.json" with { type: "json" };
console.log(config.puertoo);
const modo: "desarrollo" | "produccion" = config.modo;
tscimportar-error.ts(2,20): error TS2551: La propiedad "puertoo" no existe en el tipo "{ puerto: number; modo: string; admins: string[]; }". ¿Quería decir "puerto"?
importar-error.ts(3,7): error TS2322: El tipo 'string' no se puede asignar al tipo '"desarrollo" | "produccion"'.

Escribir JSON

Al guardar, el tipo protege lo que escribes: un objeto mal formado no compila. JSON.stringify con null, 2 lo deja con sangría:

import { readFile, writeFile } from "node:fs/promises";

interface Config {
  puerto: number;
  modo: "desarrollo" | "produccion";
  admins: string[];
}

// Al guardar, el tipo protege lo que escribes: un objeto mal formado no compila.
const nueva: Config = { puerto: 3000, modo: "desarrollo", admins: ["ana@ejemplo.com"] };
await writeFile("/tmp/config-nueva.json", JSON.stringify(nueva, null, 2) + "\n", "utf8");
console.log(await readFile("/tmp/config-nueva.json", "utf8"));
node guardar.ts
salida{
  "puerto": 3000,
  "modo": "desarrollo",
  "admins": [
    "ana@ejemplo.com"
  ]
}
FormaCuándo usarla
unknown y guarda de tipoJSON que llega de fuera y es pequeño. Sin dependencias.
import … with { type: "json" }Archivos que van con el código y que tú controlas.
Un esquema de ZodDatos de fuera con estructura grande, o que cambian a menudo.

Todo el código de esta página se comprobó con TypeScript 7.0.2 en modo estricto (--strict --locale es) y se ejecutó con Node.js 24.22 en un contenedor limpio antes de publicarla; los mensajes de error y las salidas están copiados de esa ejecución. Documentación oficial: Type predicates en el manual de TypeScript.