Building a code generator
Skir code generators are regular NPM modules loaded by the compiler. This page focuses on the integration contract: what your generator must export, what input it receives, and what output it must return.
How generators are loaded
In skir.yml, each generator is declared under generators with a mod field:
generators:
- mod: my-company-skir-gen
out:
path: generated/my-target
config:
myOption: trueThe value of mod is an NPM module name. In practice, this means you can publish your generator package to NPM, then reference that package name directly from skir.yml.
Dependencies
Your generator package should depend on skir-internal, because this package defines the public compiler/plugin contract types (for example CodeGenerator, Module, and RecordLocation). Most generators also depend on zod for configType validation.
npm i skir-internal zodRequired module export
Your package should export a single generator object named GENERATOR that implements the CodeGenerator<Config> interface:
import { type CodeGenerator } from "skir-internal";
import { z } from "zod";
const Config = z.strictObject({
// generator-specific options
myOption: z.boolean(),
});
type Config = z.infer<typeof Config>;
class MyGenerator implements CodeGenerator<Config> {
readonly id = "my-company-skir-gen";
readonly configType = Config;
generateCode(input: CodeGenerator.Input<Config>): CodeGenerator.Output {
return {
files: [
{ path: "example.txt", code: "generated" },
],
};
}
}
export const GENERATOR = new MyGenerator();Input contract
The compiler calls generateCode(input), where input has this structure:
interface CodeGeneratorInput<Config> {
readonly modules: readonly Module[];
readonly recordMap: ReadonlyMap<RecordKey, RecordLocation>;
readonly config: Config;
}modules
modules contains the parsed schema modules after resolution. Each module gives you the declarations you need for generation:
path: module path (for examplefoo/bar.skir)records: all structs/enums in that module (top-level and nested)methods: method declarations with resolved request/response typesconstants: constants with resolved types and dense JSON valuespathToImportedNames: map keyed by imported module path; each value indicates which names are imported (or that all names are imported under an alias)
recordMap
recordMap maps every RecordKey to a RecordLocation. This lookup is important because ResolvedType identifies record types with RecordKey, not RecordLocation. In other words, generators userecordMap to resolve a key into the full record metadata, including cross-module references. A RecordLocation includes:
record: the record definitionrecordAncestors: nesting chain from top-level record to the target recordmodulePath: source module where the record is defined
config
config is your generator-specific config object, validated with configType (a Zod schema). Keep this schema strict so invalid user config is rejected early.
Output contract
Your generator returns a list of output files:
interface CodeGeneratorOutput {
readonly files: readonly {
readonly path: string;
readonly code: string;
}[];
}Each entry is one generated file. path is the output relative path and code is the final file contents.
Generators do not write files to disk directly. They only return the (path, code) list; the compiler is responsible for materializing those files.
Scope of this contract
The compiler/generator contract defines data flow and types. How you map that input into language-specific APIs, classes, or style conventions is generator-specific and intentionally outside this contract.
Note
A good first step is to inspect an existing generator (for example skir-rust-gen) and focus on how it consumes modules, recordMap, and config.