Author a multi-generator stack
Write a generator whose output composes types, validators, and hooks from peer generators into one artifact — the pattern behind the stock UI generators.
When to use this
Your generator's artifact needs peer output from more than one generator (a form needs a type, a validator, and a mutation hook), and compose with another generator covers only the single-peer case.
Prerequisites
- A generator package to work in (create or clone one).
- The single-peer composition pattern from compose with another generator.
- For a fully worked reading of this pattern in stock source, the composing multi-generator stacks recipe.
Steps
1. Declare peer dependencies
Pin your peers in deno.json#imports. Use exact JSR versions
for inter-@skmtc/* package dependencies inside the SKMTC
monorepo; consumer projects can use carets. Importable as named
exports: import { TsProjection } from '@skmtc/gen-typescript'.
2. Decide your projection base
Three options:
toTsOasOperationProjectionBase— one artifact per(path, method). The form and table both use this.toTsModelProjectionBase— one artifact per schema. Used bygen-typescript,gen-zod, etc.toTsGqlOperationProjectionBase— one artifact per root field. Used by GraphQL-side generators.
Your base.ts declares id, toIdentifierName,
toIdentifierType, toExportPath, toEnrichmentSchema and
exports the resulting base class.
3. Author the Projection class
The Projection class extends your base, takes
{ context, operation, settings } (or
{ context, refName, settings, destinationPath, rootRef } for
models), and in the constructor:
- Pulls peer artifacts via
this.insertNormalizedModel(PeerProjection, args)orthis.insertOperation(PeerProjection, operation). Stash.toName()(or the fullInserted) onthis. - Constructs child Snippets and stashes them on
this. Pass each childdestinationPath: this.settings.exportPathso its imports register against your file. - Registers consumer-side library imports via
this.register({ imports: { ... } }).
The Projection's toString() interpolates the stashed names and
Snippets into a template literal. No side effects in
toString().
4. Wire it up in mod.ts
export const myEntry = toOasOperationEntry<EnrichmentSchema>({
id: denoJson.name,
isSupported, // pick which operations apply
toEnrichmentSchema, // accept user enrichments
transform: ({ context, operation }) => {
context.insertOperation({ projection: MyProjection, operation })
}
})
export default myEntryNote: transform is the entry point. It must call
context.insertOperation({ projection: MyProjection, operation })
to instantiate your Projection — without this, your class is
never constructed. See
how-generators-produce-output.md.
5. Test with a real OAS schema
Drop the generator into a project, run skmtc generate, inspect
the output and .settings/manifest.json. Items showing
'notSupported' were filtered by isSupported; items showing
'success' ran without throwing (which does not guarantee
they produced output — verify the files map).
Variations
- Operation generator depending on multiple model generators.
Pull in each via
insertNormalizedModel(MyModelProjection, { schema, fallbackName }). The pattern scales —gen-shadcn-formpulls in three TS types (request body, props, path params) plus a Zod validator. - Model generator depending on another model generator.
Possible (
gen-arktypecould depend ongen-typescriptfor base types). The Driver flow is identical — the cache key shapes are model-shaped on both sides. - GraphQL stack. Replace
toOasOperationEntrywithtoGqlOperationEntry, replace HTTP-flavored peers with the GraphQL counterpart. The coordination pattern is identical.
Common questions
How do I tell which generator a peer artifact came from?
Read the generator key on the cached Definition. The Driver
sets it via toXxxGeneratorKey({ generatorId, operation/refName })
on construction. Errors print it: a "Registered definition
mismatch" tells you both the cached and the proposed generators.
What if a peer fails to apply (its isSupported returns false for my operation)?
Your insertOperation call still returns an Inserted — but
the peer's Projection wasn't constructed and isn't in the file
map. This typically surfaces later as "Cannot find module" or
"X is not exported" when the consumer compiles the generated
output. Defensive code on your side: check the operation against
the peer's isSupported before calling.
In practice, multi-generator stacks declare their peer
dependencies tightly enough that this doesn't fire — the form
generator's isSupported is more restrictive than the type
generator's, so anything the form accepts the type generator
accepts too. Be deliberate about the isSupported boundary if
you're authoring a new stack.
Can the peer's output depend on my output?
Yes — the cache is order-independent. Whichever generator's
transform fires first will hit the cache miss and construct;
the other will hit the cache hit and reuse. The dispatcher
iterates generators in their order in the config map, but the
coordination doesn't depend on that order. See
cross-generator-coordination.md.
How tightly should I pin peer versions?
For generators inside the SKMTC monorepo (the stock catalogue), use exact JSR versions — peer-pin discipline. For generators outside (your own clones, your own packages), use whatever matches your release cadence. The clone process runs a peer-pin check; mismatches show as errors before any state mutation.
When should I split into two generators vs put everything in one?
Split when:
- Two artifacts have different export paths
(
./forms/X.tsxand./types/X.tsare clearly different files, so the type Projection and the form Projection are clearly different things). - One artifact is reusable across many consumer Projections (a TS type is reused by the form and the table; it deserves its own Projection so the cache works).
- The customization seams are independent (you might swap form libraries without swapping type libraries).
Keep in one when:
- The output is a single file containing tightly-coupled
declarations (
MockRoute+MockRoutesListingen-msw— the list aggregates the routes, both belong to the same artifact). - The dependency is acyclic and not reused (a one-off helper Snippet for path-param destructuring — a Snippet is enough; no need for a separate Projection).
Related
- Recipe: composing multi-generator stacks — the annotated read-through of ShadcnForm and ShadcnTable
- How-to: compose with another generator — the single-peer case
- Concept: cross-generator coordination