Building a Tree of the Program with XQuery
A code generator is a program that writes text, and the most direct way
to write text in XQuery is a string constructor. It starts with
``[ and ends with ]``. Everything between is text, kept
as it is, line breaks included, except for expressions written between
`{ and }`, whose values are put in:
$ sxml xquery --method=text --expression='
let $name := "FlagKey"
return ``[export function check`{ $name }`(value: unknown): boolean { return true; }]``'
export function checkFlagKey(value: unknown): boolean { return true; }
Braces, quotes and backticks are ordinary text in a string constructor, so TypeScript can be written in it as it looks. For a line or two of text with a fixed shape, that is the simplest tool there is. For whole files it gets hard to follow. Each piece of the generator then does three jobs at once: it decides what the program says (a check for each facet), how TypeScript writes it (keywords, punctuation, quotes and escapes), and how it is laid out (indentation, line breaks, empty lines). Each piece has to get all three right, and the indentation of a line depends on pieces far away. What comes out is text, which can be read but not asked anything.
So we'll split the work in two. The generator, in XQuery, builds a tree of the TypeScript program: an XML document with an element for each declaration, type, statement and expression. It decides what the program says, and nothing else. A printer, an XSLT stylesheet that the next section shows, turns the tree into text, with one template rule for each kind of element. Between the two steps, the tree is a document like any other: we can look at it, query it and test it.
A vocabulary for TypeScript
The tree needs element names. The Source Code as XML
chapter has some already: sxml:parse-code turns TypeScript into a
tree, with an element for each node of tree-sitter's grammar. Here is a
line of code from the generated checks:
$ sxml xpath --method=xml 'sxml:parse-code("const count = problems.length;", "typescript", { "highlights": false() })'
<program xmlns:sxml="urn:sxml"><lexical_declaration><sxml:token field="kind">const</sxml:token> <variable_declarator><identifier field="name">count</identifier> = <member_expression field="value"><identifier field="object">problems</identifier>.<property_identifier field="property">length</property_identifier></member_expression></variable_declarator>;</lexical_declaration></program>
That tree is concrete: every token and every space is in it, so its string value is the code. That is what makes it good for reading code, and it is why it is a poor thing to write: to write such a tree, you write the text anyway. A generator needs an abstract tree, which has only the parts that vary, and leaves the keywords, punctuation and layout to the printer. So we'll use a small vocabulary of our own, in no namespace, named after TypeScript's own words. The same line is
<const name="count"><member name="length"><name>problems</name></member></const>
and the whole vocabulary fits in a table:
| Elements | TypeScript |
|---|---|
module (with file) | a file; its children are its declarations |
comment, doc | // text and /** text */ |
import (with from, type-only) | import type { A, B } from "./types.ts"; |
type-alias | export type State = "stable" | "paused"; |
interface, property (with optional), method | export interface FlagStatus { flag: FlagKey; retryAfter?: Seconds; } |
function (with export, async), parameter, returns, body | export async function getFlags(input: FlagsRequest): Promise<FlagSet> { ... } |
type, array-type, union, predicate | Promise<FlagSet>, string[], "a" | "b", value is FlagSet |
statement, const, return, if with then and else, for-of | statements |
name, string, number, boolean, regex | value, "text", 100, false, /^[a-z0-9-]*$/u |
call (with function, or method of its first child), member, not, binary (with operator), await, array, object with entry, arrow | expressions |
A name of something, such as a function, is an attribute; the parts of
it are children. A string holds the text of the string, not the
TypeScript literal: how quotes and escapes are written is the printer's
business. An operator has a name, such as less, not a symbol, so no
< is needed in the generator.
The hand-written part
Not everything should be generated. The code that is the same for every API is written once, by hand, and the generated code calls it:
runtime.ts:
import type { IncomingMessage, ServerResponse } from "node:http";
// The hand-written part: what every generated file shares.
// The generated checks call these for the built-in XSD types.
/** A check adds a problem for each way the value is wrong. */
export type Check = (value: unknown, path: string, problems: string[]) => void;
/** The problems of a value that failed its check. */
export class InvalidValue extends Error {
problems: string[];
constructor(problems: string[]) {
super(problems.join("; "));
this.problems = problems;
}
}
/** Returns the value when it passes the check, and throws otherwise. */
export function expect<T>(value: unknown, check: Check): T {
const problems: string[] = [];
check(value, "input", problems);
if (problems.length > 0) throw new InvalidValue(problems);
return value as T;
}
export function checkString(value: unknown, path: string, problems: string[]): value is string {
if (typeof value === "string") return true;
problems.push(`${path}: expected a string`);
return false;
}
export function checkInteger(value: unknown, path: string, problems: string[]): value is number {
if (Number.isInteger(value)) return true;
problems.push(`${path}: expected an integer`);
return false;
}
export function checkDecimal(value: unknown, path: string, problems: string[]): value is number {
if (Number.isFinite(value)) return true;
problems.push(`${path}: expected a number`);
return false;
}
export function checkBoolean(value: unknown, path: string, problems: string[]): value is boolean {
if (typeof value === "boolean") return true;
problems.push(`${path}: expected true or false`);
return false;
}
export function checkDate(value: unknown, path: string, problems: string[]): value is string {
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value)) return true;
problems.push(`${path}: expected a date such as 2026-10-20`);
return false;
}
export function checkObject(value: unknown, path: string, problems: string[]): value is Record<string, unknown> {
if (typeof value === "object" && value !== null && !Array.isArray(value)) return true;
problems.push(`${path}: expected an object`);
return false;
}
/** XSD counts characters, not UTF-16 code units. */
export function characterCount(value: string): number {
return [...value].length;
}
// The client side.
/** Where the client sends its calls; a browser page keeps "/api". */
export const settings = { endpoint: "/api" };
/** Calls a remote function: a query with GET, a command with POST. */
export async function call(name: string, kind: "query" | "command", input: unknown): Promise<unknown> {
const url = `${settings.endpoint}/${name}`;
const response = kind === "query"
? await fetch(`${url}?input=${encodeURIComponent(JSON.stringify(input))}`)
: await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(input),
});
const body = await response.json();
if (!response.ok) throw new InvalidValue(body.problems ?? [`${name}: ${response.status}`]);
return body;
}
// The server side.
/** One remote function, as the server sees it. */
export interface Route {
kind: "query" | "command";
checkInput: Check;
checkOutput: Check;
run: (input: any) => Promise<unknown>;
}
interface Reply {
status: number;
body: unknown;
}
/** Answers one request: finds the route, checks the input, runs it, checks the output. */
export async function answer(routes: Record<string, Route>, method: string, url: URL, body: string): Promise<Reply> {
const name = url.pathname.startsWith("/api/") ? url.pathname.slice(5) : "";
const route = Object.hasOwn(routes, name) ? routes[name] : undefined;
if (route === undefined) return { status: 404, body: { problems: [`no function ${url.pathname}`] } };
const expected = route.kind === "query" ? "GET" : "POST";
if (method !== expected) return { status: 405, body: { problems: [`use ${expected}`] } };
let input: unknown;
try {
input = JSON.parse(route.kind === "query" ? url.searchParams.get("input") ?? "" : body);
} catch {
return { status: 400, body: { problems: ["input: expected JSON"] } };
}
const problems: string[] = [];
route.checkInput(input, "input", problems);
if (problems.length > 0) return { status: 400, body: { problems } };
const output = await route.run(input);
route.checkOutput(output, "output", problems);
if (problems.length > 0) return { status: 500, body: { problems } };
return { status: 200, body: output };
}
/** A Node.js request listener for the routes. */
export function serve(routes: Record<string, Route>) {
return async (request: IncomingMessage, response: ServerResponse) => {
let body = "";
for await (const chunk of request) body += chunk;
const url = new URL(request.url ?? "/", "http://localhost");
let reply: Reply;
try {
reply = await answer(routes, request.method ?? "GET", url, body);
} catch (error) {
reply = { status: 500, body: { problems: [String(error)] } };
}
response.writeHead(reply.status, { "content-type": "application/json" });
response.end(JSON.stringify(reply.body));
};
}
It has three groups of functions:
- Checks. A check looks at a value that came from JSON, so its type
is
unknown, and adds a message toproblemsfor each way it is wrong.pathsays where the value is, such asinput.percentage, so the messages can say it too. The checks of the built-in XSD types are here: anxs:integermust be a whole number, anxs:datea string such as2026-10-20.characterCountcounts characters as XSD does: JavaScript'slengthcounts UTF-16 code units, which differ for characters outside the Basic Multilingual Plane. - The client.
callsends a query as a GET request, with the input as JSON in the URL, and a command as a POST request, with the input as the body. - The server.
answerfinds the function that a request names, checks the method, the input and then the output, andservemakes a request listener for Node.js'shttpmodule from it.
The generator
The generator is a library module, with one function for each kind of thing it builds. Each returns elements of the vocabulary:
typescript.xqm:
(:~ Turns an XSD schema and an API description into the tree of a TypeScript program. :)
module namespace ts = "https://example.org/typescript";
declare namespace xs = "http://www.w3.org/2001/XMLSchema";
declare namespace api = "https://example.org/api";
(:~ The built-in XSD types the generator knows: their TypeScript type and check. :)
declare variable $ts:built-in := {
"xs:string": { "type": "string", "check": "checkString" },
"xs:integer": { "type": "number", "check": "checkInteger" },
"xs:decimal": { "type": "number", "check": "checkDecimal" },
"xs:boolean": { "type": "boolean", "check": "checkBoolean" },
"xs:date": { "type": "string", "check": "checkDate" }
};
declare function ts:built-in($name as xs:string) as map(*) {
$ts:built-in($name)
otherwise error(#ts:unsupported, "The generator has no mapping for " || $name)
};
(:~ The TypeScript type of an XSD type name. :)
declare function ts:type($name as xs:string) as element(type) {
<type name="{ if (starts-with($name, "xs:")) then ts:built-in($name)?type else $name }"/>
};
(:~ The name of the check function of an XSD type name. :)
declare function ts:check($name as xs:string) as xs:string {
if (starts-with($name, "xs:"))
then ts:built-in($name)?check
else "check" || $name
};
(:~ The statement problems.push(a + b + ...). :)
declare function ts:push($message as element()+) as element(statement) {
<statement>
<call method="push"><name>problems</name><binary operator="plus">{ $message }</binary></call>
</statement>
};
(:~ A statement that adds a problem when the condition holds. :)
declare function ts:problem($condition as element(), $text as xs:string) as element(if) {
<if>{ $condition }<then>{ ts:push((<name>path</name>, <string>: { $text }</string>)) }</then></if>
};
(:~ The comparison of an expression with the value of a facet. :)
declare function ts:compare($left as element(), $operator as xs:string, $value as xs:string)
as element(binary) {
<binary operator="{ $operator }">{ $left }<number>{ $value }</number></binary>
};
(:~ The check of one facet, XSD 1.1 Part 2, 4.3. :)
declare function ts:facet($facet as element()) as element(if) {
let $value := string($facet/@value)
let $characters := <call function="characterCount"><name>value</name></call>
return switch (local-name($facet)) {
case "pattern" return ts:problem(
<not><call method="test"><regex flags="u">^(?:{ $value })$</regex><name>value</name></call></not>,
"must match " || $value)
case "length" return ts:problem(
ts:compare($characters, "not-equal", $value), "must have " || $value || " characters")
case "minLength" return ts:problem(
ts:compare($characters, "less", $value), "must have at least " || $value || " characters")
case "maxLength" return ts:problem(
ts:compare($characters, "greater", $value), "must have at most " || $value || " characters")
case "minInclusive" return ts:problem(
ts:compare(<name>value</name>, "less", $value), "must be at least " || $value)
case "maxInclusive" return ts:problem(
ts:compare(<name>value</name>, "greater", $value), "must be at most " || $value)
case "minExclusive" return ts:problem(
ts:compare(<name>value</name>, "at-most", $value), "must be more than " || $value)
case "maxExclusive" return ts:problem(
ts:compare(<name>value</name>, "at-least", $value), "must be less than " || $value)
default return error(#ts:unsupported,
"The generator has no check for the facet " || local-name($facet))
}
};
(:~ A check function: it checks the base type first, then adds a problem for each rule that fails. :)
declare function ts:check-function($name as xs:string, $base as xs:string, $checks as element()*)
as element(function) {
<function name="check{ $name }" export="true">
<parameter name="value"><type name="unknown"/></parameter>
<parameter name="path"><type name="string"/></parameter>
<parameter name="problems"><array-type><type name="string"/></array-type></parameter>
<returns><predicate parameter="value"><type name="{ $name }"/></predicate></returns>
<body>
<if>
<not><call function="{ $base }"><name>value</name><name>path</name><name>problems</name></call></not>
<then><return><boolean>false</boolean></return></then>
</if>
<const name="count"><member name="length"><name>problems</name></member></const>
{ $checks }
<return>
<binary operator="equal"><member name="length"><name>problems</name></member><name>count</name></binary>
</return>
</body>
</function>
};
(:~ A simple type: a type alias and a check of its facets. :)
declare function ts:simple-type($type as element(xs:simpleType)) as element()+ {
let $name := string($type/@name)
let $base := string($type/xs:restriction/@base)
let $values := $type/xs:restriction/xs:enumeration/@value ! string()
let $facets := $type/xs:restriction/(* except xs:enumeration)
return (
<type-alias name="{ $name }" export="true">{
if (exists($values))
then <union>{ $values ! <string>{ . }</string> }</union>
else ts:type($base)
}</type-alias>,
ts:check-function($name, ts:check($base), (
if (exists($values)) {
ts:problem(
<not><call method="includes"><array>{ $values ! <string>{ . }</string> }</array><name>value</name></call></not>,
"must be one of " || string-join($values, ", "))
},
$facets ! ts:facet(.)
))
)
};
(:~ A property of a complex type: one element of its sequence. :)
declare function ts:property($element as element(xs:element)) as element(property) {
let $many := $element/@maxOccurs = "unbounded"
return <property name="{ $element/@name }">{
if ($element/@minOccurs = "0" and not($many)) { attribute optional { "true" } },
if ($many)
then <array-type>{ ts:type($element/@type) }</array-type>
else ts:type($element/@type)
}</property>
};
(:~ The check of a property: required, optional, or an array. :)
declare function ts:property-check($element as element(xs:element)) as element() {
let $name := string($element/@name)
let $check := ts:check($element/@type)
let $property := <member name="{ $name }"><name>value</name></member>
let $path := <binary operator="plus"><name>path</name><string>.{ $name }</string></binary>
return
if ($element/@maxOccurs = "unbounded") then
<if>
<call method="isArray"><name>Array</name>{ $property }</call>
<then>
<statement>
<call method="forEach">{ $property }
<arrow>
<parameter name="item"/>
<parameter name="index"/>
<call function="{ $check }">
<name>item</name>
<binary operator="plus">
<name>path</name><string>.{ $name }[</string><name>index</name><string>]</string>
</binary>
<name>problems</name>
</call>
</arrow>
</call>
</statement>
</then>
<else>{ ts:push((<name>path</name>, <string>.{ $name }: expected an array</string>)) }</else>
</if>
else if ($element/@minOccurs = "0") then
<if>
<binary operator="not-equal">{ $property }<name>undefined</name></binary>
<then>
<statement><call function="{ $check }">{ $property, $path }<name>problems</name></call></statement>
</then>
</if>
else
<statement><call function="{ $check }">{ $property, $path }<name>problems</name></call></statement>
};
(:~ A complex type: an interface and a check of its properties. :)
declare function ts:complex-type($type as element(xs:complexType)) as element()+ {
let $name := string($type/@name)
let $elements := $type/xs:sequence/xs:element
return (
<interface name="{ $name }" export="true">{ $elements ! ts:property(.) }</interface>,
ts:check-function($name, "checkObject", (
$elements ! ts:property-check(.),
<for-of name="key">
<call method="keys"><name>Object</name><name>value</name></call>
<body>
<if>
<not>
<call method="includes">
<array>{ $elements/@name ! <string>{ string(.) }</string> }</array>
<name>key</name>
</call>
</not>
<then>{
ts:push((<name>path</name>, <string>.</string>, <name>key</name>, <string>: is not allowed</string>))
}</then>
</if>
</body>
</for-of>
))
)
};
(:~ A module: a comment, the imports its declarations need, and the declarations.
A name is imported from types.ts when that module declares it,
and a called function that no module of the program declares from runtime.ts. :)
declare function ts:module($file as xs:string, $comment as xs:string,
$declarations as element()*, $types as element(module)?) as element(module) {
let $declared := $declarations/@name
let $from-types := function($names) {
distinct-values($names[. = $types/*/@name][not(. = $declared)])
}
let $type-names := $from-types($declarations//type/@name)
let $value-names := $from-types($declarations//(call/@function | name))
let $runtime-names := sort(distinct-values(
$declarations//call/@function[not(. = ($declared, $types/*/@name))]))
return
<module file="{ $file }">
<comment>{ $comment }</comment>
{
if (exists($runtime-names)) {
<import from="../runtime.ts">{ $runtime-names ! <name>{ . }</name> }</import>
},
if (exists($type-names)) {
<import from="./types.ts" type-only="true">{ $type-names ! <name>{ . }</name> }</import>
},
if (exists($value-names)) {
<import from="./types.ts">{ $value-names ! <name>{ . }</name> }</import>
},
$declarations
}
</module>
};
(:~ types.ts: every type of the schema, in the schema's order. :)
declare function ts:types($schema as document-node()) as element(module) {
ts:module("types.ts", "Generated from the XSD schema by program.xq and print.xsl. Do not edit.",
$schema/xs:schema/(xs:simpleType | xs:complexType) !
(if (self::xs:simpleType) then ts:simple-type(.) else ts:complex-type(.)),
())
};
(:~ The remote functions of an API description, after checking that their types exist. :)
declare function ts:functions($api as document-node()) as element()* {
let $schema := doc(resolve-uri($api/api:api/@types, base-uri($api)))
let $names := $schema/xs:schema/xs:complexType/@name ! string()
for $function in $api/api:api/(api:query | api:command)
for $type in ($function/@input, $function/@output)
where not($type = $names)
return error(#ts:unknown-type,
"The function " || $function/@name || " uses the type " || $type
|| ", which the schema doesn't declare"),
$api/api:api/(api:query | api:command)
};
(:~ The names of the clients that the API description offers, sorted. :)
declare function ts:client-names($api as document-node()) as xs:string* {
sort(distinct-values($api/api:api/*/@clients ! tokenize(.)))
};
(:~ The module of one client, such as browser.ts: one async function for
each remote function that the client offers. :)
declare function ts:client($api as document-node(), $client as xs:string, $types as element(module))
as element(module) {
ts:module($client || ".ts", "Generated from the API description by program.xq and print.xsl. Do not edit.",
for $function in ts:functions($api)[tokenize(@clients) = $client]
let $input := string($function/@input)
let $output := string($function/@output)
return
<function name="{ $function/@name }" export="true" async="true">
<doc>{ normalize-space($function) }</doc>
<parameter name="input"><type name="{ $input }"/></parameter>
<returns><type name="Promise"><type name="{ $output }"/></type></returns>
<body>
<statement>
<call function="expect"><type name="{ $input }"/><name>input</name><name>{ ts:check($input) }</name></call>
</statement>
<const name="output">
<await>
<call function="call">
<string>{ string($function/@name) }</string>
<string>{ local-name($function) }</string>
<name>input</name>
</call>
</await>
</const>
<return>
<call function="expect"><type name="{ $output }"/><name>output</name><name>{ ts:check($output) }</name></call>
</return>
</body>
</function>,
$types)
};
(:~ server.ts: the interface the service implements, and a request handler
that checks every function, whichever client calls it. :)
declare function ts:server($api as document-node(), $types as element(module)) as element(module) {
let $functions := ts:functions($api)
return ts:module("server.ts", "Generated from the API description by program.xq and print.xsl. Do not edit.", (
<interface name="Implementation" export="true">
<doc>What the service does: one method for each remote function.</doc>
{
for $function in $functions
return
<method name="{ $function/@name }">
<doc>{ normalize-space($function) }</doc>
<parameter name="input"><type name="{ $function/@input }"/></parameter>
<returns><type name="Promise"><type name="{ $function/@output }"/></type></returns>
</method>
}
</interface>,
<function name="createHandler" export="true">
<doc>A Node.js request listener that checks every input and output.</doc>
<parameter name="implementation"><type name="Implementation"/></parameter>
<body>
<return>
<call function="serve">
<object>{
for $function in $functions
return
<entry name="{ $function/@name }">
<object>
<entry name="kind"><string>{ local-name($function) }</string></entry>
<entry name="checkInput"><name>{ ts:check($function/@input) }</name></entry>
<entry name="checkOutput"><name>{ ts:check($function/@output) }</name></entry>
<entry name="run">
<arrow>
<parameter name="input"/>
<call method="{ $function/@name }"><name>implementation</name><name>input</name></call>
</arrow>
</entry>
</object>
</entry>
}</object>
</call>
</return>
</body>
</function>
), $types)
};
Let's read it from the top.
$ts:built-inmaps the built-in XSD types that the API uses to their TypeScript type and their check.ts:typemakes atypeelement for a type name, andts:checkgives the name of its check; a name of the schema keeps its name. A built-in type that isn't in the map is an error, not a guess.ts:pushandts:problembuild the statement that every rule ends in:problems.push(...), with the message, inside anif.ts:facetturns one facet into oneif. Apatternbecomes a regular expression with^(?:and)$around it, because an XSD pattern must match the whole value (XSD 1.1 Part 2, appendix G). Length facets count characters. A facet the generator doesn't know is an error: a generator that left a rule out without saying so would make code that accepts what the schema refuses.ts:check-functionbuilds the shape that every check shares: check the base type, remember the number of problems, run the rules, and return whether none was added.ts:simple-typegives it the facets of anxs:simpleType, after a type alias; with enumeration facets, the type is a union of string literals, so the TypeScript compiler knows the values too.ts:propertyandts:property-checkhandle one element of a sequence: required, optional, or an array.ts:complex-typebuilds an interface and a check, which also refuses properties that the schema doesn't have.ts:moduleputs declarations into amodule, with the imports they need, which it finds by asking the declarations: a type or a name thattypes.tsdeclares is imported from there, and a function that is called but declared nowhere in the program comes fromruntime.ts. Nobody keeps a list of imports by hand.ts:typesbuildstypes.ts: every type of the schema, in the order of the schema.ts:functionsreturns the functions of the description, after it has checked that their types are complex types of the schema.ts:client-nameslists the names in theclientsattributes, once each and sorted, andts:clientbuilds the module of one of them, such asbrowser.ts, with the functions that name it.ts:serverbuildsserver.ts, with every function: whichever client sends a request, the service checks it.
The XSD types are read as XML, with paths such as
xs:restriction/@base. This is the schema as a document, not the
compiled schema that sxml validate uses: a generator needs to know
how the types are written, and paths are the simplest way to ask.
Trying the pieces
As with any library module, try the functions on their own first. One
facet becomes one if, with the comparison and the message:
$ sxml xquery --method=xml --expression='
import module namespace ts = "https://example.org/typescript" at "typescript.xqm";
declare namespace xs = "http://www.w3.org/2001/XMLSchema";
declare option output:indent "yes";
ts:facet(<xs:maxInclusive value="100"/>)'
<if>
<binary operator="greater">
<name>value</name>
<number>100</number>
</binary>
<then>
<statement>
<call method="push">
<name>problems</name>
<binary operator="plus">
<name>path</name>
<string>: must be at most 100</string>
</binary>
</call>
</statement>
</then>
</if>
The tree is longer than the line of code it stands for, but each part
has a name, so a test can ask for a part instead of comparing text. The
operator and the number of a maxLength facet:
$ sxml xquery --expression='
import module namespace ts = "https://example.org/typescript" at "typescript.xqm";
declare namespace xs = "http://www.w3.org/2001/XMLSchema";
ts:facet(<xs:maxLength value="64"/>)/binary ! (@operator, number) ! string()'
greater 64
Given to the printer of the next section, which reads a document from
standard input when its input is -, the tree of a facet is one line
of TypeScript:
$ sxml xquery --method=xml --expression='
import module namespace ts = "https://example.org/typescript" at "typescript.xqm";
declare namespace xs = "http://www.w3.org/2001/XMLSchema";
ts:facet(<xs:pattern value="[a-z0-9][a-z0-9-]*"/>)' | sxml transform print.xsl -
if (!/^(?:[a-z0-9][a-z0-9-]*)$/u.test(value)) problems.push(path + ": must match [a-z0-9][a-z0-9-]*");
A simple type is a type alias and a check function. Put into a
module, the printer writes them as a file would have them:
$ sxml xquery --method=xml --expression='
import module namespace ts = "https://example.org/typescript" at "typescript.xqm";
declare namespace xs = "http://www.w3.org/2001/XMLSchema";
<module>{ ts:simple-type(doc("vane-api.xsd")//xs:simpleType[@name = "Percentage"]) }</module>' | sxml transform print.xsl -
export type Percentage = number;
export function checkPercentage(value: unknown, path: string, problems: string[]): value is Percentage {
if (!checkInteger(value, path, problems)) return false;
const count = problems.length;
if (value < 0) problems.push(path + ": must be at least 0");
if (value > 100) problems.push(path + ": must be at most 100");
return problems.length === count;
}
The facets minInclusive and maxInclusive became the two if
statements. The checks are type predicates: when checkInteger
returns true, TypeScript knows that value is a number in the lines
after it. A complex type becomes an interface:
$ sxml xquery --method=xml --expression='
import module namespace ts = "https://example.org/typescript" at "typescript.xqm";
declare namespace xs = "http://www.w3.org/2001/XMLSchema";
<module>{ ts:complex-type(doc("vane-api.xsd")//xs:complexType[@name = "FlagStatus"]) }</module>' | sxml transform print.xsl -
export interface FlagStatus {
flag: FlagKey;
state: State;
failedChecks: FailureCount;
retryAfter?: Seconds;
}
export function checkFlagStatus(value: unknown, path: string, problems: string[]): value is FlagStatus {
if (!checkObject(value, path, problems)) return false;
const count = problems.length;
checkFlagKey(value.flag, path + ".flag", problems);
checkState(value.state, path + ".state", problems);
checkFailureCount(value.failedChecks, path + ".failedChecks", problems);
if (value.retryAfter !== undefined) checkSeconds(value.retryAfter, path + ".retryAfter", problems);
for (const key of Object.keys(value)) {
if (!["flag", "state", "failedChecks", "retryAfter"].includes(key)) problems.push(path + "." + key + ": is not allowed");
}
return problems.length === count;
}
And a facet without a translation stops the generator:
$ sxml xquery --expression='
import module namespace ts = "https://example.org/typescript" at "typescript.xqm";
ts:facet(<xs:totalDigits value="5"/>)'
typescript.xqm:73:20: ts:unsupported: The generator has no check for the facet totalDigits
The tree of the whole program
A main module puts it together. It reads the description and its
schema, and returns a program with a module for each file:
program.xq:
(:~ The tree of the TypeScript program for an API description and its schema:
the types, one module for each client, and the server. :)
import module namespace ts = "https://example.org/typescript" at "typescript.xqm";
declare option output:method "xml";
declare option output:indent "yes";
declare variable $api-file as xs:string external := "api.xml";
let $api := doc($api-file)
let $types := ts:types(doc(resolve-uri($api/*/@types, base-uri($api))))
return <program>{
$types,
ts:client-names($api) ! ts:client($api, ., $types),
ts:server($api, $types)
}</program>
The output options in the prolog, as in Writing
Results, write the tree as indented XML, so
it is easy to read and to compare. The modules are built in the order
of the files that use each other: types.ts first, because
ts:module looks into it to find the imports of the others. The
clients come next, one for each name, and the server last.
$ sxml xquery --output=program.xml program.xq
$ sxml xpath --context-item=program.xml '/program/module ! `{ @file }: { count(descendant::*) } elements`'
"types.ts: 1427 elements"
"browser.ts: 62 elements"
"console.ts: 62 elements"
"node.ts: 62 elements"
"server.ts: 138 elements"
The tree is a document, so everything in this book about querying documents applies to the program. Every exported function, by file:
$ sxml xpath --context-item=program.xml '/program/module ! `{ @file }: { string-join(function[@export]/@name, ", ") }`'
"types.ts: checkFlagKey, checkOwner, checkEnvironment, checkState, checkPercentage, checkFailureCount, checkSeconds, checkRule, checkFlagConfig, checkFlagsRequest, checkFlagSet, checkAttribute, checkEvaluationContext, checkEvaluation, checkEvaluations, checkStatusRequest, checkFlagStatus, checkRolloutRequest, checkKillRequest, checkKillResult"
"browser.ts: evaluateFlags, getStatus"
"console.ts: rollOutFlag, killFlag"
"node.ts: getFlags, getStatus"
"server.ts: createHandler"
The checks that use checkFlagKey, which are the ones a change to the
type of a flag key reaches:
$ sxml xpath --context-item=program.xml '//function[.//(call/@function | name) = "checkFlagKey"]/@name ! string()'
"checkFlagConfig"
"checkEvaluation"
"checkStatusRequest"
"checkFlagStatus"
"checkRolloutRequest"
"checkKillRequest"
"checkKillResult"
And the imports of the frontend SDK's client, which ts:module found in the tree:
$ sxml xpath --context-item=program.xml '/program/module[@file = "browser.ts"]/import ! `{ @from }: { string-join(name, ", ") }`'
"../runtime.ts: call, expect"
"./types.ts: EvaluationContext, Evaluations, StatusRequest, FlagStatus"
"./types.ts: checkEvaluationContext, checkEvaluations, checkStatusRequest, checkFlagStatus"
None of these needs to know how TypeScript writes a function or an import; asked of the text, each would need a parser.
The rule the schema couldn't check
ts:functions checks that each function's types exist. With a typo in
a type name, the generator stops with its own error, and writes no
file, because --output writes its file only when the whole query
succeeds:
$ sed 's/output="KillResult"/output="KillResults"/' api.xml > typo.xml
$ sxml xquery --param=api-file=typo.xml --output=typo-program.xml program.xq
typescript.xqm:248:10: ts:unknown-type: The function killFlag uses the type KillResults, which the schema doesn't declare
$ test -e typo-program.xml || echo "no file written"
no file written
Next, let's print the tree.