schemaBuilder
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 }; }