Skip to main content

Tutorial: writing a generator from scratch

This walks through building a small but real generator - OpenAPI schemas → TypeScript interfaces - step by step. Every command below was actually run against this repo's test/resources/petstore.yaml fixture; the output shown is real, not illustrative.

Prerequisites: a built openapi-yagen binary (see the root README.md for build instructions) and a spec to generate from - we use test/resources/petstore.yaml throughout.

1. An empty generator​

Create a new directory for the generator with two files:

generator.yml:

name: openapi_to_ts
description: Tutorial generator - OpenAPI schemas to TypeScript interfaces
mainScriptPath: main.js

main.js:

dump(Object.keys(schema.components.schemas));

Run it:

openapi-yagen g -o out -g ./openapi_to_ts -c test/resources/petstore.yaml
Dump: [Error,Pet,Pets]

schema is the fully-resolved OpenAPI document (see javascript-api.md) - schema.components.schemas is a plain object keyed by schema name, exactly like the spec's own components.schemas.

2. Your first template​

Generators write files via renderTemplate (see javascript-api.md). Add a template with static content first, to see the plumbing work before adding real logic:

models.ts.j2:

// Generated by openapi_to_ts - do not edit by hand.

export interface Placeholder {
id: string;
}

main.js:

renderTemplate("models.ts.j2", {}, "models.ts");

Running the same command as above now produces out/models.ts with exactly that content.

3. Real data: object schemas → interfaces​

Now build the actual model list in main.js, using kindOf to pick out object schemas and map each property's OpenAPI type to a TypeScript one:

function mapType(propSchema) {
if (kindOf(propSchema) === "Enum") return propSchema.enum.map((v) => JSON.stringify(v)).join(" | ");
if (propSchema.type === "integer" || propSchema.type === "number") return "number";
if (propSchema.type === "string") return "string";
if (propSchema.type === "boolean") return "boolean";
return "unknown";
}

const models = [];
for (const [name, s] of Object.entries(schema.components.schemas)) {
if (kindOf(s) !== "Object") continue;
const required = new Set(s.required || []);
const properties = Object.entries(s.properties || {}).map(([propName, propSchema]) => ({
name: propName,
type: mapType(propSchema),
required: required.has(propName),
}));
models.push({ name, properties });
}

renderTemplate("models.ts.j2", { models }, "models.ts");

{name, properties} is a plain object built in JS before the template is rendered - not the raw schema object. That matters: kindOf (and nameOf, used later) only work on schemas that still have their original JS identity, which is gone once something crosses into a template (see javascript-api.md).

4. The template renders interfaces​

// Generated by openapi_to_ts - do not edit by hand.

{% for model in models -%}
export interface {{ model.name }} {
{% for p in model.properties -%}
{{ p.name }}{% if not p.required %}?{% endif %}: {{ p.type }};
{% endfor -%}
}

{% endfor -%}

(The -%} trims are explained in templating.md - without them you'd get a blank line after every tag.)

openapi-yagen g -o out -g ./openapi_to_ts -c test/resources/petstore.yaml
cat out/models.ts
// Generated by openapi_to_ts - do not edit by hand.

export interface Error {
code: number;
message: string;
}

export interface Pet {
id: number;
name: string;
tag?: string;
}

(This minimal example doesn't reindent the property loop, hence the flat output - see templating.md for indent()/{% filter %}, which the built-in generators use to emit correctly nested code directly. Either reach for those in your own templates, or pipe output through a formatter via the -p/--post-process flag in the root README.md.)

5. A generator variable​

Add a readonly variable so callers can opt into readonly properties without forking the generator:

generator.yml:

name: openapi_to_ts
description: Tutorial generator - OpenAPI schemas to TypeScript interfaces
mainScriptPath: main.js

variables:
- name: readonly
description: Generate readonly properties
defaultValue: "false"

main.js (last line only):

renderTemplate("models.ts.j2", { models, readonly: vars.readonly === "true" }, "models.ts");

models.ts.j2 (property line only):

{% if readonly %}readonly {% endif %}{{ p.name }}{% if not p.required %}?{% endif %}: {{ p.type }};
openapi-yagen g -o out -g ./openapi_to_ts -c test/resources/petstore.yaml -v "readonly=true"
export interface Pet {
readonly id: number;
readonly name: string;
readonly tag?: string;
}

Omit -v readonly=... and it falls back to defaultValue: "false" - properties come out non-readonly, as in step 4.

6. Arrays and enums​

Two more schema shapes, both handled the same way: classify with kindOf, then look up the right TypeScript spelling.

Arrays (Pets in petstore.yaml is type: array, items: $ref: Pet) become a type alias. nameOf recovers Pet from the (already $ref-resolved) items schema:

const aliases = [];
for (const [name, s] of Object.entries(schema.components.schemas)) {
const kind = kindOf(s);
if (kind === "Object") {
// ... same as step 3 ...
} else if (kind === "Array") {
const itemType = nameOf(s.items) || mapType(s.items);
aliases.push({ name, itemType });
}
}
renderTemplate("models.ts.j2", { models, aliases, readonly: vars.readonly === "true" }, "models.ts");
{% for alias in aliases -%}
export type {{ alias.name }} = {{ alias.itemType }}[];

{% endfor -%}
openapi-yagen g -o out -g ./openapi_to_ts -c test/resources/petstore.yaml
cat out/models.ts
export type Pets = Pet[];

(appended after the two interfaces from step 4 - full file unchanged otherwise)

Enums were actually already handled - look back at mapType in step 3: if (kindOf(propSchema) === "Enum") return propSchema.enum.map((v) => JSON.stringify(v)).join(" | "). petstore.yaml doesn't have one, but adding

status:
type: string
enum: [available, sold]

to Pet's properties and regenerating produces exactly what you'd expect:

export interface Pet {
id: number;
name: string;
status?: "available" | "sold";
tag?: string;
}

7. Where to go next​

This tutorial's generator is deliberately small. For fuller, real-world examples, see generators/:

If you want your own generator to live in this repo's generators/ collection with a real, self-contained test suite (not just "does it compile" - actually generating, compiling, and running the output), see generators/README.md for the README.md/src//test/ convention and how the Kotlin generators' test/ directories are built.