anserran.dev

Manipulación avanzada de tipos con TypeScript

Actualizado el 8 sept

Para definir y guardar el contenido de algunos de mis sitios web quiero utilizar archivos en vez de una base de datos. Por ejemplo, para los artículos y categorías del sitio web que lees, el contenido sería tal que así:

blog/
├── articulos/
│   ├── tipos-avanzados-typescript/
│   │   └── post.json
│   └── un-triangulo-de-colores/
│       └── post.json
└── categorias/
    ├── typescript/
    │   └── category.json
    └── short-story/
        └── category.json

Hay dos tipos de contenidos: entradas de blog (post) y categorías a las que pertenecen (category), y sus atributos y contenido están definidos en archivos JSON.

Ahora quiero crear un sistema que lea estos archivos, se asegure que el JSON que viene sea conforme a lo que espero, y los convierta en páginas HTML. Y todo ello utilizando el poder de tipos que ofrece TypeScript para ayudarme mientras programo.

Interfaces y esquemas de validación

Empiezo definiendo un par de interfaces que representen los tipos de contenidos de mi web:

interface Category {
  id: number;
  name: string;
}

interface Post {
  content: string;
  categoryId: number;
}

y sus esquemas con JSON-Schema, que nos permite definir reglas de validación para objetos JSON:

const schemas = {
  Category: {
    type: "object",
    properties: {
      id: {
        type: "number",
      },
      name: {
        type: "string",
      },
    },
  },
  Post: {
    type: "object",
    properties: {
      content: {
        type: "string",
      },
      categoryId: {
        type: "number",
      },
    },
  },
};

Y con esto puedo empezar montar lo que necesito:

function readContent(contentDir: string) {
  for (const name of readdirSync(contentDir)) {
    const path = join(contentDir, name);
    if (name === "post.json") {
      const post = JSON.parse(readFileSync(path, "utf8")) as Post;
      validate(post, schemas.Post);
      renderPost(post);
    } else if (name === "category.json") {
      const category = JSON.parse(
        readFileSync(path, "utf8"),
      ) as Category;
      validate(category, schemas.Category);
      renderCategory(category);
    }
  }
}

delegando la validación del contenido y el renderizado de HTML a funciones que implementaré después.

Llegados a este punto, hay algo que me empieza a picar. Planeo tener bastantes tipos de contenidos para mi sitio web (cursos, lecciones, experimentos, páginas sueltas...) y con esta disposición voy a tener que crear, por cada nuevo tipo de contenido, por un lado su interfaz y por otro su esquema de validación.

Pero miro el código de los esquemas y toda la información que necesito para generar las interfaces asociadas está ahí, definida como JSON-Schema. Debe haber una manera de extraerla y deshacerme de las redundantes interfaces.

Extrayendo los tipos de mis contenidos a partir de un JSON-Schema

Mi objetivo es deshacerme de las interfaces por completo y deducir los tipos de mis contenidos a partir de sus esquemas. Conseguir que este código infiera los tipos de mis contenidos sin necesidad de ser explícito:

function readContent(contentDir: string) {
  for (const name of readdirSync(contentDir)) {
    const path = join(contentDir, name);
    if (name === "post.json") {
      const post = parseContent("Post", schemas, path);
      // Type: { content: string, categoryId: number }
    } else if (name === "category.json") {
      const category = parseContent("Category", schemas, path);
      // Type: { id: number, name: string }
    }
  }
}

La chicha está en parseContent que infiere el tipo de un contenido a partir del nombre de su tipo.

Voy a definir una primera versión, aún sin considerar la resolución de tipos que buscamos:

function parseContent(
  contentType: string,
  schemas: Record<string, unknown>,
  path: string,
) {
  const value = JSON.parse(readFileSync(file, "utf8"));
  validate(value, schemas[contentType]);
  return value;
  // Type: any
}

Aquí parseContent devuelve any, que es el tipo que devuelve JSON.parse. Y esto compila porque any lo aguanta todo.

Lo que quiero para esta función es que sea lo más exacta posible en sus tipos de entrada y salida.

keyof y typeof

Para el tipo de salida de la función tengo claro que quiero lo que hasta ahora tengo definido con interfaces, pero también puedo hacer los tipos de entrada más exactos.

Por ejemplo, contentType no puede ser cualquier string. Debe ser "Category" o "Post":

function parseContent(
  contentType: "Category" | "Post",
  schemas: Record<string, unknown>,
  path: string,
);

pero esta opción me obliga a actualizar el tipo en contentType cada vez que añada un nuevo tipo de contenido a mi proyecto. Y de nuevo siento que estoy añadiendo información redundante, puesto que esa lista de valores ya existe en el objecto schemas. ¿Y si pudiera derivarla de ahí?

Tenemos el operador keyof, que extrae los atributos del tipo de un objecto y crea una unión de strings con ellos:

function parseContent(
  contentType: keyof typeof schemas,
  // Type: "Category" | "Post"
  schemas: Record<string, unknown>,
  path: string,
);

Aquí también utilizamos tyepof, que convierte una variable en su tipo para manipulaciones posteriores:

typeof schemas
=>
{
  Category: {
    type: string;
    properties: {
      name: {
        type: string;
      };
    };
  };
  Post: {
    type: string;
    properties: {
      content: {
        type: string;
      };
      categoryId: {
        type: string;
      };
    };
  };
}
keyof typeof schemas
=>
"Category" | "Post"

Genéricos y restricciones

Con esto he mejorado un poco los tipos de entrada de la función, pero que el tipo de contentType dependa de una variable reduce su portabilidad. Podemos eliminarlo utilizando un tipo genérico:

function parseContent<S>(
  contentType: keyof S,
  schemas: S,
  path: string,
);

S es una variable de tipo que podemos referenciar desde otros tipos de nuestra función. Ahora, cuando llamemos a la función con el objecto schemas, TypeScript comprobará de manera automática que contentType es un atributo de schemas.

Podemos hacer esta función aún más exacta, añadiendo restricciones de tipo a S. Con:

function parseContent<S extends Record<string, unknown>>(
  contentType: keyof S,
  schemas: S,
  path: string,
);

indicamos a TypeScript que schemas debe ser un objeto con propiedades y valores, (representado por el tipo Record<string, unknown> , que es otra manera de especificar {[name: string]: unknown}. Y tendríamos:

parseContent(
  "Category",
  {
    Category: {
      // ...
    },
    Post: {
      // ...
    },
  },
  "category.json",
);
// ✅ "Category" asignable a tipo "Category" | "Post"

parseContent(
  "Tag",
  {
    Category: {
      // ...
    },
    Post: {
      // ...
    },
  },
  "category.json",
);
// ❌ "Tag" no asignable a tipo "Category" | "Post"

parseContent("Category", [], "category.json");
// ❌ [] no asignable a tipo Record<string, unknown>

Pero puedo ir más allá con esta restricción definiendo los tipos de los valores de schemas:

type Schema =
  | { type: "string" }
  | { type: "number" }
  | { type: "object"; properties: Record<string, Schema> };

que es la unión de tres tipos, cada uno de ellos definiendo el JSON Schema de un string, number y object respectivamente. Y ahora modifico la restricción en S:

function parseContent<S extends Record<string, Schema>>(
  contentType: keyof S,
  schemas: S,
  path: string,
);

Con esto hemos conseguido utilizar todo el poder de tipos de TypeScript para los parámetros de entrada:

parseContent(
  "Category",
  {
    Category: {
      type: "object",
      properties: {
        // ...
      },
    },
    // ✅ Asignable a tipo Schema
  },
  "category.json",
);

parseContent(
  "Category",
  {
    Category: ["string"],
    // ❌ string[] no asignable a tipo Schema
  },
  "category.json",
);

Ahora tenemos pendiente hacer lo mismo con el de salida. Tenemos S con los esquemas de validación, y sabemos que dentro de ellos se encuentra la información que buscamos. ¿Cómo podríamos extraerla?

Creando tipos a partir de otros tipos

Voy a empezar por el final, suponiendo que ya lo he resuelto y que cuento con un tipo ToInterface que dado los esquemas y el nombre de un tipo de contenido me devuelve lo que busco:

function parseContent<S extends Record<string, Schema>>(
  contentType: keyof S,
  schemas: S,
  path: string,
): ToInterface<S, keyof S>;

ToInterface<S, keyof S> recibe dos tipos, el de los esquemas (S) y del tipo del contenido (keyof S), y devuelve la interfaz asociada tal y como la habíamos definido al principio de manera explícita.

Si consideráramos los contenidos para nuestro sitio web:

const category: ToInterface<typeof schemas, "Category">;
// Type: { id: number, name: string };
const post: ToInterface<typeof schemas, "Post">;
// Type: { content: string, categoryId: string };

Ahora necesito implementarlo. Tengo el siguiente paso claro: el tipo Schema es el que voy a tener que transformar en interfaz. Puedo definir:

type ToInterface<
  Schemas extends Record<string, Schema>,
  ContentType extends keyof Schemas,
> = SchemaType<Schemas[ContentType]>;

type SchemaType<S extends Schema> = {/* ??? */};

SchemaType<S extends Schema> recibe un JSON-Schema y lo transforma en la interfaz que representa. ToInterface lo utiliza para resolver lo que busca, utilizando ContentType para sacar el tipo de Schemas haciendo uso de acceso indexado. En la práctica lo que sucede es

ToInterface<typeof schemas, "Category">
=>
SchemaType<(typeof schemas)["Category"]>
=>
SchemaType<{
  type: "object",
  properties: { id: { type: "number" }, name: { type: "string" }}}
}>

Solo queda implementar SchemaType.

Tipos condicionales

TypeScript soporta tipos condicionales, que nos permiten detectar la forma de un tipo variable y actuar en consecuencia. Con:

type SchemaType<S extends Schema> = S extends { type: "string" }
  ? string
  : never;

especificamos que si el tipo variable S extiende { type: "string" }) entonces el tipo devuelto es string. En la rama negativa de la condición devolvemos never, que indica que no pudimos retornar un tipo válido.

Los tipos condicionales pueden tener condiciones anidadas:

type SchemaType<S extends Schema> = S extends { type: "string" }
  ? string
  : S extends { type: "number" }
    ? number
    : never;

Donde TypeScript va evaluando las ramas condicionales por orden y devolviendo el tipo que toque.

Tipos mapeados y recursión

Para completar todo el soporte de schemas que manejamos, solo me queda añadir el manejo de { type: "object" }:

type SchemaType<S extends Schema> = S extends { type: "string" }
  ? string
  : S extends { type: "number" }
    ? number
    : S extends { type: "object" }
      ? {
          [K in keyof S["properties"]]: SchemaType<
            S["properties"][K]
          >;
        }
      : never;

para el que utilizo un tipo mapeado recursivo. A simple vista, es algo complejo, pero si seguimos la resolución con un ejemplo podemos ver como se acaba transformando en lo que buscamos:

type Category = {
  type: "object",
  properties: { id: { type: "number" }, name: { type: "string" }}}
}

SchemaType<Category>
=>
{
  [K in keyof Category["properties"]]: SchemaType<Category["properties"][K]>
}
=>
{
  id: SchemaType<Category["properties"]["id"]>,
  name: SchemaType<Category["properties"]["name"]>,
}
=>
{
  id: SchemaType<{type: "number"}>,
  name: SchemaType<{type: "string"}>,
}
=>
{
  id: number,
  name: string,
}

Si ahora vuelvo a nuestra función:

function parseContent<S extends Record<string, Schema>>(
  contentType: keyof S,
  schemas: S,
  path: string,
): ToInterface<S, keyof S> {
  /* ... */
}

const post = parseContent("Post", schemas, "post.json");
// Type: string | number | { [p: string]: string | number }

pasa algo raro, porque la función devuelve un valor cuyo tipo parece ser la unión de todos los posibles esquemas. Es como si ToInterface no supiera qué escoger.

Tiene sentido, porque no hay ninguna vinculación entre contentType, quien define el tipo de contenido, y el tipo de salida. Ambos mencionan keyof S, pero eso no es suficiente para ToInterface. Hay que ser más explícito:

function parseContent<
  S extends Record<string, Schema>,
  C extends keyof S,
>(contentType: C, schemas: S, path: string): ToInterface<S, C>;

const seo = parseContent("seo", schemas, "seo.json");
// Type: { title: string, description: string }

Ahora sí ToInterface sabe que debe tomar el valor de contentType para escoger el esquema adecuado.

Inferencia de tipos

Finalmente tenemos todo montado:

import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";

type Schema =
  | { type: "string" }
  | { type: "number" }
  | { type: "object"; properties: Record<string, Schema> };

type ToInterface<
  Schemas extends Record<string, Schema>,
  ContentType extends keyof Schemas,
> = SchemaType<Schemas[ContentType]>;

type SchemaType<S extends Schema> = S extends { type: "string" }
  ? string
  : S extends { type: "number" }
    ? number
    : S extends { type: "object" }
      ? {
          [K in keyof S["properties"]]: SchemaType<
            S["properties"][K]
          >;
        }
      : never;

const schemas = {
  Category: {
    type: "object",
    properties: {
      id: {
        type: "number",
      },
      name: {
        type: "string",
      },
    },
  },
  Post: {
    type: "object",
    properties: {
      content: {
        type: "string",
      },
      categoryId: {
        type: "number",
      },
    },
  },
} satisfies Record<string, Schema>;

function parseContent<
  S extends Record<string, Schema>,
  C extends keyof S,
>(contentType: C, schemas: S, path: string): ToInterface<S, C> {
  const value = JSON.parse(readFileSync(path, "utf8"));
  validate(value, schemas[contentType]);
  return value;
}

function validate(value: unknown, schema?: Schema) {
  // Validar...
}

function renderCategory(
  category: SchemaType<(typeof schemas)["Category"]>,
) {
  // Renderizar HTML para categoría...
}

function renderPost(post: SchemaType<(typeof schemas)["Post"]>) {
  // Renderizar HTML para entrada...
}

function readContent(contentDir: string) {
  for (const name of readdirSync(contentDir)) {
    const path = join(contentDir, name);
    if (name === "post.json") {
      const post = parseContent("Post", schemas, path);
      // Type: { content: string, categoryId: number }
      renderPost(post);
    } else if (name === "category.json") {
      const category = parseContent("Category", schemas, path);
      // Type: { id: number, name: string }
      renderCategory(category);
    }
  }
}

Una última cosa: satisfies Record<string, Schema> al final de la definición de schemas no es un capricho, sino una parte fundamental para que no se nos derrumbe el castillo que hemos montado.

Si no usamos satisfies y definimos schemas sin ningún tipo, TypeScript infiere uno automáticamente:

const schemas = { /* ... */ };
typeof schemas
=>
{
  Category: {
    type: string;
    properties: {
      id: {
        type: string;
      };
      name: {
        type: string;
      };
    };
  };
  Post: {
    type: string;
    properties: {
      content: {
        type: string;
      };
      categoryId: {
        type: string;
      };
    };
  };
};

Observa que tanto los tipos de Category: { type: string } y Post: { type: string } donde type es de tipo string. Sin embargo, nuestro tipo condicional SchemaType comprueba que type sea un literal igual a "string", "number" u "object". Cuando SchemaType recibe {type: string} (que es diferente de {type: "string"}), no puedo resolver el tipo de manera adecuada y:

SchemaType<{
  type: string;
  properties: {
    id: {
      type: string;
    };
    name: {
      type: string;
    };
  };
}>;
// ❌ string no asignable a tipo literal "object"

Así que dejar schemas sin tipo no funciona, pero, ¿y si somos explícitos?

const schemas: Record<string, Schema> = {/* ... */};

Con esto, TypeScript no infiere nada, y pasa el tipo explícito hacia abajo, resultando en esto:

SchemaType<Schema>
=>
string | number | {[p: string]: SchemaType<Record<string, Schema>[string]>}

Como SchemaType recibe un Schema genérico no puede decidir por qué rama condicional tirar, y las da todas por buenas, resultando en una unión de todas las posibilidades. Que tampoco nos vale.

Pero finalmente con:

const schemas = {/* ... */} satisfies Record<string, Schema>;

chivamos a TypeScript que el objeto contiene Schema como valores, lo que le permite a SchemaType deducir que type debe ser "string" | "number" | "object" y resolver sus condiciones de manera adecuada, y a la vez no eliminamos sus detalles de tipo tal y como hacíamos con la definición de tipo explícita.

Conclusión

Con casi todos los trucos que nos ofrece TypeScript, hemos logrado crear un código que elimina redundancias y que nos garantiza que los valores que vamos pasando de un lado a otro tienen los tipos adecuados.

Si quieres profundizar más en los conceptos que hemos usado, puedes hacerlo en la documentación oficial de TypeScript, que es fantástica (en inglés):

  1. Operador keyof
  2. Operador typeof
  3. Genéricos
  4. Creando tipos a partir de otros
  5. Tipos de acceso indexado
  6. Tipos condicionales
  7. Tipos mapeados
  8. Reducción e inferencia de tipos