schemaBuilder

tcmts.core.schema.schemaBuilder · #52b4i4e2 · term · runs anywhere

Turns TypeScript types into schemas; recursive types share defs.

import { GLOBAL_CLASSES } from "#codebase/tcmts/core/schema";
import type { Schema } from "#codebase/tcmts/core/schema";
import type { TypeChecker } from "#codebase/tcmts/core/ts";
import {
  ElementFlags as ts_ElementFlags, IndexKind as ts_IndexKind, ObjectFlags as ts_ObjectFlags,
  SymbolFlags as ts_SymbolFlags, TypeFlags as ts_TypeFlags,
} from "typescript/sync";
import type {
  NumberLiteralType as ts_NumberLiteralType, ObjectType as ts_ObjectType, StringLiteralType as ts_StringLiteralType,
  Type as ts_Type, TypeReference as ts_TypeReference, UnionOrIntersectionType as ts_UnionOrIntersectionType,
} from "typescript/sync";

export function schemaBuilder(checker: TypeChecker) {
  const defs: Schema[] = [];
  const refs = new Map<ts_Type, number>();
  const active = new Set<ts_Type>();
  const done = new Map<ts_Type, Schema>();

  const of = (t: ts_Type, depth: number): Schema => {
    if (depth > 30) return { t: "any" };
    const f = t.flags;
    if (f & (ts_TypeFlags.Any | ts_TypeFlags.Unknown)) return { t: "any" };
    if (f & ts_TypeFlags.Never) return { t: "never" };
    if (f & ts_TypeFlags.StringLiteral)
      return { t: "literal", value: (t as ts_StringLiteralType).value };
    if (f & ts_TypeFlags.NumberLiteral)
      return { t: "literal", value: (t as ts_NumberLiteralType).value };
    if (f & ts_TypeFlags.BooleanLiteral)
      return { t: "literal", value: checker.typeToString(t) === "true" };
    if (f & (ts_TypeFlags.String | ts_TypeFlags.TemplateLiteral | ts_TypeFlags.StringMapping))
      return { t: "string" };
    if (f & ts_TypeFlags.Number) return { t: "number" };
    if (f & ts_TypeFlags.Boolean) return { t: "boolean" };
    if (f & (ts_TypeFlags.BigInt | ts_TypeFlags.BigIntLiteral)) return { t: "bigint" };
    if (f & ts_TypeFlags.Null) return { t: "null" };
    if (f & (ts_TypeFlags.Undefined | ts_TypeFlags.Void)) return { t: "undefined" };
    if (f & ts_TypeFlags.TypeParameter) {
      const c = checker.getBaseConstraintOfType(t);
      return c && c !== t ? of(c, depth + 1) : { t: "any" };
    }
    if (f & ts_TypeFlags.Union)
      return {
        t: "union",
        of: (t as ts_UnionOrIntersectionType).getTypes().map((u) => of(u, depth + 1)),
      };
    if (!(f & ts_TypeFlags.Object) && !(f & ts_TypeFlags.Intersection)) return { t: "any" };

    // object types can be recursive: meeting one again while describing it reserves a shared definition
    if (active.has(t)) {
      if (!refs.has(t)) refs.set(t, defs.push({ t: "any" }) - 1);
      return { t: "ref", id: refs.get(t)! };
    }
    // a type reached again by another route is described once: shared, so it's written once too (the schema reached
    // first becomes the reference, where it is, and what it said a shared definition)
    const seen = done.get(t);
    if (seen) {
      if (["object", "tuple", "array", "map", "set"].includes(seen.t)) {
        const id = defs.push({ ...seen }) - 1;
        for (const k of Object.keys(seen)) delete (seen as Record<string, unknown>)[k];
        Object.assign(seen, { t: "ref", id });
      }
      return seen;
    }
    active.add(t);
    const s = objectSchema(t, depth);
    active.delete(t);
    const id = refs.get(t);
    const result: Schema = id === undefined ? s : ((defs[id] = s), { t: "ref", id });
    done.set(t, result);
    return result;
  };

  const objectSchema = (t: ts_Type, depth: number): Schema => {
    const name = t.getSymbol()?.name;
    const reference =
      !!(t.flags & ts_TypeFlags.Object) &&
      !!((t as ts_ObjectType).objectFlags & ts_ObjectFlags.Reference);
    const args = reference ? checker.getTypeArguments(t as ts_TypeReference) : [];
    if (checker.isTupleType(t)) {
      const target = (t as ts_TypeReference).getTarget() as unknown as {
        elementFlags: readonly number[];
      };
      const elems = args.map((a) => of(a, depth + 1));
      const flags = target.elementFlags;
      const restAt = flags.findIndex((x) => x & ts_ElementFlags.Variable);
      const fixed = restAt === -1 ? elems : elems.slice(0, restAt);
      return {
        t: "tuple",
        of: fixed,
        optional: flags.filter((x) => x & ts_ElementFlags.Optional).length,
        ...(restAt === -1 ? {} : { rest: elems[restAt] }),
      };
    }
    if (checker.isArrayType(t)) return { t: "array", of: of(args[0]!, depth + 1) };
    if (name && GLOBAL_CLASSES.has(name) && isGlobal(t)) {
      if (name === "Date") return { t: "date" };
      if (name.endsWith("Map"))
        return { t: "map", key: of(args[0]!, depth + 1), value: of(args[1]!, depth + 1) };
      return { t: "set", of: of(args[0]!, depth + 1) };
    }
    if (t.getCallSignatures().length || t.getConstructSignatures().length)
      return { t: "unsendable", what: "a function" };
    if (t.getSymbol()?.flags && t.getSymbol()!.flags & ts_SymbolFlags.Class)
      return { t: "unsendable", what: `a ${name} instance` };
    // Node's own object types (Buffer, streams, ...) are host objects, not plain data
    if (name && isHostType(t)) return { t: "unsendable", what: `a ${name}` };
    const props: Record<string, { s: Schema; optional: boolean }> = {};
    for (const p of checker.getPropertiesOfType(t)) {
      const s = of(checker.getTypeOfSymbol(p), depth + 1);
      const optional = (p.flags & ts_SymbolFlags.Optional) !== 0;
      // an object that must hold a function (a method) can't arrive as data either: one schema says so
      if (s.t === "unsendable" && !optional)
        return {
          t: "unsendable",
          // (a well-known symbol's key, `__@iterator@<the compiler's id>`, as it's written: `[Symbol.iterator]`)
          what: `${name && name !== "__type" && name !== "__object" ? `a ${name}` : "an object"} (its \`${p.name.replace(/^__@(\w+)@\d+$/, "[Symbol.$1]")}\` is ${s.what})`,
        };
      props[p.name] = { s, optional };
    }
    const index = checker.getIndexInfoOfType(t, ts_IndexKind.String);
    return { t: "object", props, ...(index ? { index: of(index.valueType, depth + 1) } : {}) };
  };

  const isGlobal = (t: ts_Type) =>
    (t.getSymbol()?.declarations ?? []).some((d) => /\.d\.[cm]?ts$/.test(d.path));
  /** Declared by @types/node (Buffer, streams, ...), rather than by code or a package. */
  const isHostType = (t: ts_Type) =>
    (t.getSymbol()?.declarations ?? []).some((d) =>
      /[\\/]node_modules[\\/]@types[\\/]node[\\/]/.test(d.path),
    );
  return { of, defs };
}

uses

used by