JSON Schema permite definir una estructura estricta para los datos transmitidos entre aplicaciones que se comunican.

Introducción

En las aplicaciones web es habitual trabajar con datos complejos que deben transmitirse entre distintas aplicaciones.

Crea un proyecto nuevo con el nombre json-schema:

> mkdir json-schema
> cd json-schema
> bun init

Tipo

Al deserializar un string JSON puedes definir su estructura mediante un tipo para proporcionar un mejor autocompletado, comprobación de errores y legibilidad.

Modifica el fichero index.ts

type Person = {
    name: string,
    age: number
}

let str = '{ "name": "Eva", "age": 33}'

let person: Person = JSON.parse(str)

console.log(person.name)

Si ejecutas este código, funciona sin problemas ya que el objeto deserializado tiene la propiedad name:

> bun.exe .\index.ts
Eva

Sin embargo, esto es sólo informativo, ya que JSON.parse no verifica que el string contiene un objeto compatible con el tipo de la variable:

type Person = {
    name: string,
    age: number
}

let str = '{"brand": "Seat", "model": "Ibiza"}'

let person: Person = JSON.parse(str)

console.log(person.name)

Si ejecutas el código el resultado es undefined porque el objeto deserializado no tiene la propiedad name:

> bun.exe .\index.ts
undefined

Y aunque en este caso el problema sea inocuo, no sucede lo mismo con este código:

type Person = {
    name: string,
    age: number
    address: Address 
}

type Address = {
    street: string,
    city: string
}

let str = '{"brand": "Seat", "model": "Ibiza"}'

let person: Person = JSON.parse(str)

console.log(person.address.city)

Si lo ejecutas, tienes un error grave en tiempo de ejecución:

> bun.exe .\index.ts
12 |
13 | let str = '{"brand": "Seat", "model": "Ibiza"}'
14 |
15 | let person: Person = JSON.parse(str)
16 |
                        ^
TypeError: undefined is not an object (evaluating 'person.address.city')
      

Esquema

Un objeto JSON no incluye ningún contexto ni metadatos, y no hay forma de saber con solo mirar el objeto qué significan las propiedades o cuáles son los valores admitidos.

A continuación tienes un objeto que pertenece a un catalogo de productos:

{
    "id": 65,  
    "name": "A green door",  
    "price": 12.50,  
    "tags": [ "home", "green" ]
}

Un esquema es un documento JSON que contiene la descripción y restricciones que tiene que tener un documento JSON para que sea conforme a ese esquema y, por tanto, una instancia de ese esquema.

Crea un fichero product.json.

El esquema más básico es un objeto JSON en blanco, que no restringe nada, no permite nada y no describe nada:

{}

Agregando propiedades de validación al esquema puedes aplicar restricciones a una instancia.

Por ejemplo, puedes usar la propiedad "type" para restringir una instancia a un objeto, array, string, number, boolean o null:

{ "type": "string" }

Para crear una definición de esquema básica, define las siguientes propiedades:

  • $schema: especifica a qué "draft" del estándar de esquema JSON se adhiere el esquema.
  • $id: establece una URI para el esquema. Puedes utilizar esta URI única para hacer referencia a elementos del esquema desde dentro del mismo documento o desde documentos JSON externos.
  • title y description: indican la intención del esquema. Estas propiedades no añaden ninguna restricción a los datos que se están validando.
  • type: define la primera restricción de los datos JSON. En el ejemplo del catálogo de productos que aparece a continuación, esta propiedad especifica que los datos deben ser un objeto JSON.

Por ejemplo:

{
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "$id": "https://example.com/product.schema.json",
    "title": "Product",
    "description": "A product in the catalog",
    "type": "object"
}

Propiedades

properties es una propiedad de validación que consiste en un objeto donde cada propiedad representa una propiedad de los datos JSON que se están validando.

También puedes especificar qué propiedades definidas en el objeto son obligatorias.

Usando el ejemplo del catálogo de productos, id es un valor numérico que identifica de forma única un producto. Dado que este es el identificador canónico del producto, es obligatorio.

Agrega la propiedad de validación properties al esquema:

{
    ...
    "type": "object",
    "properties": {}
}

Agrega la propiedad id junto con las siguientes anotaciones:

  • description: indica que id es el identificador único del producto.
  • type define el tipo de datos que son válidos, en este caso el identificador del producto debe ser un número.
{
    ...
    "type": "object",
    "properties": {
        "id": {
            "description": "The unique identifier for a product",
            "type": "integer"
        }
    }
}

Agrega la propiedad name de tipo string:

{
    ...
    "type": "object",
    "properties": {
        "id": {
            "description": "The unique identifier for a product",
            "type": "integer"
        },
        "name": {
            "description": "Name of the product",
            "type": "string"
        }
    }
}

Continua llegint: https://json-schema.org/learn/getting-started-step-by-step

Ajv

Ajv es un validador al que le pasas un esquema para tus datos JSON y lo convierte en un código TypeScript muy eficiente que valida tus datos de acuerdo con el esquema.

Instala ajv:

> bun add ajv

Crea un esquema de validación con dos propiedades id y name:

const productSchema = {
    type: "object",
    properties: {
        id: { type: "integer" },
        name: { type: "string" }
    }
}

A continuación crea una función de validación a partir de este esquema:

import Ajv from "ajv"

const productSchema = {
    type: "object",
    properties: {
        id: { type: "integer" },
        name: { type: "string" }
    },
    required: ["id", "name"]
}

const ajv = new Ajv()
const validateProduct = ajv.compile(productSchema)

Ajv compila el esquema en un función y la almacena en caché (usando el esquema mismo como clave en un mapa), de modo que la próxima vez que se use el mismo objeto de esquema no se volverá a compilar.

Ya puedes utilizar la función que has creado para validar si un objecto es conforme al schema productSchema:

const apple = { "id": 1, "name": "apple" }

if (!validateProduct(apple)) {
    console.log("apple is not an apple 🤨")
} else {
    console.log("Let's eat the 🍎")
}

Si ejecutas el código puedes ver que nos podemos comer la manzana porque es un objecto que tiene las propiedades que se le pide a un producto:

> bun .\index.ts
Let's eat the 🍎

Y no nos comemos la manzana si no tiene las propiedades requeridas:

const validateProduct = ajv.compile(productSchema)

const apple = { "name": "apple" }

if (!validateProduct(apple)) {
    console.log("apple is not a product 🤨")
} else {
    console.log("Let's eat the 🍎")
}

Pero ya sabes que TypeScript utiliza "duck typing".

Por tanto, este objecto que representa una persona, aunque sea correcto a nivel de código no está permitido en la vida real ...

const eva = { "id": 1, "name": "Eva" }

if (!validateProduct(eva)) {
    console.log("eva is not an product 🤨")
} else {
    console.log("Let's eat 👩")
}

Ya se, ya se, hace muchos años Eva podía ser un producto comestible, pero hoy en día no se admite 🫡 ... los estudiantes son demasiado flexibles en el momento de revisar la nota.

De todas formas, este producto, aunque didáctico, no está muy bien diseñado para lo que lo estamos utilizando.

Modifica el esquema del producto y mejora el código:

import Ajv from "ajv"

const productSchema = {
    type: "object",
    properties: {
        id: { type: "integer" },
        name: { type: "string" },
        icon: { type: "string" },
        edible: { type: "boolean" }
    },
    required: ["id", "name", "icon", "edible"]
}

const ajv = new Ajv()
const validateProduct = ajv.compile(productSchema)

const product = JSON.parse('{ "id": 1, "name": "orange", "icon": "🍊", "edible": true }')

if (!validateProduct(product)) {
    console.log("product is not a product 🤨")
} else {
    if (product.edible) {
        console.log(`Let's eat the ${product.icon}`)
    }
}

Puedes ver que ahora nos comemos la naranja 🍊 porque és un producto comestible ("edible").

Y ... todavía se puede mejorar más porque TypeScript és "script" con tipos:

import Ajv from "ajv"

type Product = {
    id: number,
    name: string,
    icon: string,
    edible: boolean
}

const productSchema = {
    type: "object",
    properties: {
        id: { type: "integer" },
        name: { type: "string" },
        icon: { type: "string" },
        edible: { type: "boolean" }
    },
    required: ["id", "name", "icon", "edible"]
}

const ajv = new Ajv()
const validateProduct = ajv.compile(productSchema)

const product: Product = JSON.parse('{ "id": 1, "name": "orange", "icon": "🍊", "edible": true }')

if (!validateProduct(product)) {
    console.log("product is not a Product 🤨")
} else {
    if (product.edible) {
        console.log(`Let's eat the ${product.icon}`)
    }
}

Sigue: https://ajv.js.org/guide/typescript.html

Schema generator

A veces te interesa generar un esquema JSON a partir de la definición de tipos TypeScript.

Crea el fichero post.ts:

export type Post = {
    id: string
    createdAt: string
    updatedAt: string
    title: string
    content: string | null
    published: boolean
    authorId: string
}

A continuación, genera un esquema JSON para la interfaz Post:

> bunx ts-json-schema-generator -p post.ts --type 'Post' -f .\tsconfig.json
{
  "$ref": "#/definitions/Post",
  "$schema": "http://json-schema.org/draft-07/schema#",
  "definitions": {
    "Post": {
        ...

Puedes ver que el esquema se imprime en el terminal.

Si quieres puedes guardar el esquema en el fichero post.json redirigiendo la salida:

> bunx ts-json-schema-generator -p post.ts --type 'Post' -f .\tsconfig.json > post.json

Otra opción es generar el esquema mediante un script.

Instala la librería ts-json-schema-generator:

> bun add -d ts-json-schema-generator

Modifica el fichero schema.ts:

export { }

import tsj from "ts-json-schema-generator"

/** @type {import('ts-json-schema-generator/dist/src/Config').Config} */
const config = {
    path: "./post.ts",
    tsconfig: "./tsconfig.json",
    type: "*", // O <type-name> si sólo quieres generar un esquema para un tipo concreto 
}

const outputPath = "post.json";

const schema = tsj.createGenerator(config).createSchema(config.type);
const schemaString = JSON.stringify(schema, null, 2);

await Bun.write(outputPath, schemaString)

Ya puedes generar el esquema vía código (e integrar este proceso en la construcción del proyecto)

 bun .\schema.ts

Puedes importar el fichero JSON al script post.ts:

import postSchema from './post.json'

Y utilizarlo para validar objectos tipo Post:

import Ajv from 'ajv'
import postSchema from './post.json' with { type: "json" }

export type Post =  {
    id: string
    createdAt: string
    updatedAt: string
    title: string
    content: string | null
    published: boolean
    authorId: string
}

const ajv = new Ajv()
const validatePost = ajv.compile(postSchema)

const post: Post = JSON.parse('{ "id": 12345, "title": "JSON validation" }')

console.log(`post validated? ${validatePost(post)}`)

Fetch

Normalmente la validación se ejecuta con datos externo que consigues mediante APIs REST.

Es muy importante que valides esos datos porque nunca puedes estar seguro que los datos que recibes sean correctos.

A continuación utilizaremos datos "fake" de https://jsonplaceholder.typicode.com/.

Modifica el fichero index.ts:

const response = await fetch('https://jsonplaceholder.typicode.com/users')
const users = await response.json()

console.log(users.map((user: { name: string }) => user.name))

Este código baja un JSON con todos los usuarios y muestra por terminal el nombre de los usuarios:

bun run .\index.ts
[ "Leanne Graham", "Ervin Howell", "Clementine Bauch", "Patricia Lebsack", "Chelsey Dietrich",
  "Mrs. Dennis Schulist", "Kurtis Weissnat", "Nicholas Runolfsdottir V", "Glenna Reichert",
  "Clementina DuBuque"
]

A continuación crea el fichero typicode.ts con los tipos correspondientes:

export type User = {
    id: string
    name: string
    username: string
    email: string
    address: Address
    phone: string
    website: string
    company: Company
}

type Address = {
    street: string
    suite: string
    city: string
    zipcode: string
    geo: Geo
}

type Geo = {
    lat: number
    lng: number
}

type Company = {
    name: string
    catchPhrase: string
    bs: string
}

Utiliza el tipo User del script typicode.ts y muestra el nombre de las empresas dónde trabajan los usuarios:

import type { User } from "./typicode"

const response = await fetch('https://jsonplaceholder.typicode.com/users')
const users: User[] = await response.json()

console.log(users.map(user => user.company.name))

Crea el fichero typicode.json con el esquema de validación:

> bunx ts-json-schema-generator -p .\typicode.ts  -f .\tsconfig.json > .\typicode.json

Valida los datos de usuarios:

import Ajv from "ajv"
import type { User } from "./typicode"
import userSchema from "./tipycode.json"


const response = await fetch('https://jsonplaceholder.typicode.com/users')
const users: User[] = await response.json()

const ajv = new Ajv()
const validateUser = ajv.compile(userSchema)
users.filter(user => validateUser(user))

console.log(users.map(user => user.company.name))

Actividad - DummyJSON

En DummyJSON tienes datos de prueba bastante reales

1.- En la url https://dummyjson.com/recipes tienes una lista de recetas.

import type { Recipe } from "./dummyjson"

const response = await fetch('https://dummyjson.com/recipes')
const data = await response.json()
const recipes: Recipe[] = data.recipes

console.log(recipes.map(recipe => recipe.name))

Crea el fichero dummyjson.ts con los tipos correspondientes, el esquema de validación, etc.

dummyjson.ts

export type Recipe = {
    id: number
    name: string
    ingredients: string[]
    instructions: string[]
    prepTimeMinutes: number
    cookTimeMinutes: number
    servings: number
    difficulty: string
    cuisine: string
    caloriesPerServing: number
    tags: string[]
    userId: number
    image: string
    rating: number
    reviewCount: number
    mealType: string[]
}

2.- Haz lo mismo con otro tipo de datos

Acitividad - AEMET

Ves a la página de AEMET y registra-te para obtener una API Key: AEMET OpenData.

Crea el fichero aemet.ts

Guarda tu API Key en una variable (esta es del profesor)

const API_KEY = 'eyJzdWIiOiJkZGVtaW5nb0B4dGVjLmNhdCIsImp0aSI6ImE4N2I3M2Y5LWQ0YWYtNDc2My1hMzcwLTZlOTZjZjRiM2UxYSIsImlzcyI6IkFFTUVUIiwiaWF0IjoxNzI0MTc4MTg4LCJ1c2VySWQiOiJhODdiNzNmOS1kNGFmLTQ3NjMtYTM3MC02ZTk2Y2Y0YjNlMWEiLC'

En esta página tienes una descripción de todos los servicios de consulta disponibles:AEMET OpenData - Data specification

Si miras la documentació puedes ver que hay un endpoint que te permite consultar la predicción específica diaria de un municipio: /api/prediccion/especifica/municipio/diaria/{municipio}.

Los códigos de población los puedes encontrar en este enlace: Código de municipio.

A continuación consultaremos la predicción específica diaria del municipio de barcelona que tiene el código 08019:

export {}

const API_KEY = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJkZGVtaW5nb0B4dGVjLmNhdCIsImp0aSI6ImE4N2I3M2Y5LWQ0YWYtNDc2My1hMzcwLTZlOTZjZjRiM2UxYSIsImlzcyI6IkFFTUVUIiwiaWF0IjoxNzI0MTc4MTg4LCJ1c2VySWQiOiJhODdiNzNmOS1kNGFmLTQ3NjMtYTM3MC02ZTk2Y2Y0YjNlMWEiLCJyb2xlIjoiIn0.oWM1ZjREwwETKbc7ezINar03jJu832wR883EQYI6o_w'

const response = await fetch(`https://opendata.aemet.es/opendata/api/prediccion/especifica/municipio/diaria/08019/?api_key=${API_KEY}`)
const data = await response.json()

console.log(data)

El resultado es un fichero JSON donde estan los datos:

> bun run .\aemet.ts
{
  descripcion: "exito",
  estado: 200,
  datos: "https://opendata.aemet.es/opendata/sh/a5a38371",
  metadatos: "https://opendata.aemet.es/opendata/sh/dfd88b22",
}

puedes hacer un fecth a datos:

export {}

const API_KEY = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJkZGVtaW5nb0B4dGVjLmNhdCIsImp0aSI6ImE4N2I3M2Y5LWQ0YWYtNDc2My1hMzcwLTZlOTZjZjRiM2UxYSIsImlzcyI6IkFFTUVUIiwiaWF0IjoxNzI0MTc4MTg4LCJ1c2VySWQiOiJhODdiNzNmOS1kNGFmLTQ3NjMtYTM3MC02ZTk2Y2Y0YjNlMWEiLCJyb2xlIjoiIn0.oWM1ZjREwwETKbc7ezINar03jJu832wR883EQYI6o_w'

let response = await fetch(`https://opendata.aemet.es/opendata/api/prediccion/especifica/municipio/diaria/08019/?api_key=${API_KEY}`)
let data = await response.json()

response = await fetch(data.datos)
data = await response.json()

console.log(data)

Tendríamos que comprobar que el estado es 200, pero al principi hay que escribir un código que funcione, más adelante ya lo modificarás para que funcione bien.

> bun run .\aemet.ts
[
  {
    origen: {
      productor: "Agencia Estatal de Meteorolog�a - AEMET. Gobierno de Espa�a",
      web: "https://www.aemet.es",
      enlace: "https://www.aemet.es/es/eltiempo/prediccion/municipios/barcelona-id08019",
      language: "es",
      copyright: "� AEMET. Autorizado el uso de la informaci�n y su reproducci�n citando a AEMET como autora de la misma.",
      notaLegal: "https://www.aemet.es/es/nota_legal",
    },
    elaborado: "2025-02-24T21:02:11",
    nombre: "Barcelona",
    provincia: "Barcelona",
    prediccion: {
      dia: [
        [Object ...], [Object ...], [Object ...], [Object ...], [Object ...], [Object ...], [Object ...]
      ],
    },
    id: 8019,
    version: 1,
  }
]

A continuació modifica el código para que muestre las predicciones de temperatura máxima de cada día:

response = await fetch(data.datos)
data = await response.json()

console.log(data[0].prediccion.dia.map (dia => dia.temperatura.maxima))

A continuación:

  • Crea los tipos y el esquema de validacion correspondiente
  • Pregunta al usuario la ciudad que hay que consultar.

TODO