Composing multi-generator stacks
How a Projection coordinates with multiple peer generators to build a full-stack artifact (a form wired to types, validators, and a mutation hook). Worked through @skmtc/gen-shadcn-form and…
What you'll learn
The pattern that the SKMTC stock catalogue calls "UI" generators
— the ones that compose tier-1 (types, validators) and tier-2
(query hooks, MSW handlers) output into a rendered React
component. By the end you'll be able to read ShadcnForm.ts and
know exactly what each insert* call buys you, and you'll have
a template for writing your own.
Two concrete generators serve as the reference:
@skmtc/gen-shadcn-form(the most-coordinated example) — pulls in TypeScript types, Zod validators, a TanStack Query mutation hook, and a property-by-property field-renderer dispatch.@skmtc/gen-shadcn-table(the simpler example) — pulls in a column definition Projection and a TanStack Query list hook.
Stack
- An OAS-side stock generator family. The same patterns apply to
GraphQL-side generators via
toGqlOperationEntry. - Peer dependencies declared in
deno.jsonunder the generator's own package, pinned to compatible versions:
// gen-shadcn-form/deno.json
{
"imports": {
"@skmtc/core": "jsr:@skmtc/core@^0.0.51",
"@skmtc/gen-typescript": "jsr:@skmtc/gen-typescript@^0.0.55",
"@skmtc/gen-zod": "jsr:@skmtc/gen-zod@^0.0.55",
"@skmtc/gen-tanstack-query-supabase-zod": "jsr:@skmtc/gen-tanstack-query-supabase-zod@^0.0.55",
"@skmtc/gen-shadcn-select": "jsr:@skmtc/gen-shadcn-select@^0.0.55"
}
}The dependency declaration is at the package level. SKMTC itself has no plugin registry; you import what you need directly.
The coordination pattern
Every multi-generator Projection's constructor does the same three things, in this order:
- Pull in peer artifacts via
this.insertNormalizedModel(...)andthis.insertOperation(...). Each call returns anInsertedwhose.toName()is the peer's identifier name. - Register consumer-side library imports via
this.register({ imports })— the things the rendered template will reference that aren't produced by a peer (react-hook-form,@hookform/resolvers/zod, etc.). - Stash names on
thissotoString()can interpolate them into the rendered template.
The pattern relies on the pull-based Projection
model:
every insert* call instantiates the peer Projection on demand
(or hits the cache if a previous call already did). The
Driver
handles the cache, the cross-file import stitch, and the
integrity check.
Walkthrough — ShadcnForm
The constructor of gen-shadcn-form's ShadcnForm class
(skmtc-generators/gen-shadcn-form/src/ShadcnForm.ts:18-93),
walked step by step. The full source is the canonical reference;
this walkthrough names what each step buys you.
(a) Find the request body schema.
const requestBody = operation.toRequestBody(({ schema }) => schema)
invariant(requestBody, 'Request body is required')If no request body, the form has nothing to render — the
invariant makes that fatal at construction time.
(b) Pull in the TypeScript type for the request body.
const tsRequestBody = this.insertNormalizedModel(TsProjection, {
schema: requestBody,
fallbackName: `${capitalize(settings.identifier.name)}Body`
})
this.tsRequestBodyName = tsRequestBody.identifier.nameIf requestBody is a $ref, this delegates to insertModel and
hits the existing TsProjection for that ref. If it's inline,
this constructs a one-off TsProjection under <FormName>Body.
Either way, tsRequestBody.identifier.name is the peer's
identifier — stashed on this for later interpolation.
(c) Same dance for the Zod validator.
const zodRequestBody = this.insertNormalizedModel(ZodProjection, {
schema: requestBody,
fallbackName: `${decapitalize(settings.identifier.name)}Body`
})
this.zodRequestBodyName = zodRequestBody.identifier.nameTwo facts buried here: (1) ZodProjection and TsProjection
have different ids, so their Definitions live at different
(name, exportPath) cache keys; (2) typical conventions are
title-cased for the TS type, camel-cased for the Zod validator
— hence the capitalize / decapitalize divergence.
(d) Build a synthetic schema for the form's props.
const formArgsSchema = operation
.toParametersObject()
.addProperty({
name: 'defaultValues',
schema: new CustomValue({
context,
value: `Required<${this.tsRequestBodyName}>`
}),
required: false
})
.addProperty({
name: 'onSuccess',
schema: new CustomValue({
context,
value: `() => void`
}),
required: false
})toParametersObject() returns an OasObject derived from the
operation's path/query parameters. addProperty extends it with
two extra fields (defaultValues, onSuccess) that don't come
from the schema — they're consumer-side conveniences. The
CustomValue escape hatch lets us inject TypeScript expressions
(Required<...>, a function type) that aren't expressible as OAS
schemas.
(e) Build the per-field renderer dispatch.
this.fields = new FormFields({ context, operation, settings })FormFields is a Snippet whose toString() interpolates one
field-renderer Snippet per request-body property. The dispatch
mechanism is described in
"The field-renderer dispatch"
below.
(f) Pull in a TS type for the form's props.
const typeDefinition = this.insertNormalizedModel(TsProjection, {
schema: formArgsSchema,
fallbackName: `${settings.identifier.name}Props`
})
// The form's `props` arg is wrapped in a FunctionParameter Snippet
// using `typeDefinition`. Stored on `this.parameter` for the
// rendered function signature — see source for the constructor call.The form's props parameter needs a typed signature
((props: <FormName>Props) => ...). That type is produced by a
second TsProjection against the synthetic schema from step (d).
(g) Pull in the TanStack Query mutation hook.
this.clientName = this.insertOperation(TanstackQuery, operation).toName()This is the biggest cross-generator pull. TanstackQuery is its
own Projection whose constructor recursively pulls in its own
peers (a fetcher, a Zod parser, etc.) — the coordination cascades.
The form only needs the name; the Driver handles the rest.
(h) Side-effect: produce a path-params type for sibling consumers.
this.insertNormalizedModel(TsProjection, {
schema: operation.toParametersObject(['path']),
fallbackName: capitalize(`${settings.identifier.name}PathParams`)
})We don't use the path-params type in the form's own template,
but a sibling generator (gen-shadcn-table row click handlers,
for instance) may. Registering it here makes it available without
forcing the sibling to re-derive it. This is "pre-positioning"
peer artifacts.
(i) Demo-stitch: register an import in @/demo.tsx.
context.register({
imports: { [this.settings.exportPath]: [this.settings.identifier.name] },
destinationPath: join('@', 'demo.tsx')
})A scaffolded demo page imports every generated form. The form generator registers the import side-effect-style — a useful side-effect-via-register pattern for "things that need to know about every generated artifact of this kind."
(j) Register consumer-side library imports.
this.register({
imports: {
'@hookform/resolvers/zod': ['zodResolver'],
'react-hook-form': ['useForm'],
'@/components/ui/form': ['Form'],
'@/components/ui/button': ['Button'],
'@hookform/lenses': ['useLens'],
react: ['useEffect']
}
})The six things the rendered template will reference. Imports
travel via register because the file map's dedup and
verbatim-syntax-aware rendering live there — inline import
lines in template literals bypass both. See
stringable-composition.md.
By the time the constructor returns, the file map has:
- A
<FormName>BodyTypeScript type (fromTsProjection). - A
<formName>BodyZod validator (fromZodProjection). - A
<FormName>PropsTypeScript type (fromTsProjection). - A
<FormName>PathParamsTypeScript type (fromTsProjection). - A
use<Operation>mutation hook (fromTanstackQuery). - Cross-file imports stitched between all of these and the form's own file.
- Six consumer-side library imports registered to the form's file.
The form's own Definition (the export const <FormName> = …)
hasn't been registered yet — that's the Driver's job, after the
constructor returns.
The render layer
toString() then interpolates the peer names into a JSX
template:
override toString(): string {
const { title, description, submitLabel } = this.settings.enrichments.subject ?? {}
return `(${this.parameter}) => {
const form = useForm<Required<${this.tsRequestBodyName}>>({
resolver: zodResolver(${this.zodRequestBodyName}.required()),
defaultValues: props.defaultValues
})
const mutator = ${this.clientName}()
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((body, event) => {
event?.preventDefault()
mutator.mutate({ ...props, body })
})}>
${this.fields}
<Button type="submit">${submitLabel || 'Submit'}</Button>
</form>
</Form>
)
}`
}Three peer-name interpolations (${this.tsRequestBodyName},
${this.zodRequestBodyName}, ${this.clientName}), one Snippet
interpolation (${this.fields}), one enrichment-driven label
fallback. The constructor did all the registration work; the
render is pure string composition.
Walkthrough — ShadcnTable (simpler)
gen-shadcn-table has a shorter constructor
(skmtc-generators/gen-shadcn-table/src/ShadcnTable.ts:15-45):
constructor({ context, operation, settings }: ConstructorArgs) {
super({ context, operation, settings })
const { schema, key } = toListKeyAndItem(operation)
this.listKey = key.join('.')
// Column definitions — produced by a sibling Projection in the
// same package, registered with `noExport: true` because it's
// a local helper, not a public artifact.
this.columnsName = this.insertOperation(TanstackColumns, operation, { noExport: true }).toName()
// The list-fetching hook.
this.clientName = this.insertOperation(TanstackQuery, operation).toName()
// Path-params destructuring helper Snippet (local to this Projection).
this.pathParams = new PathParams({
context, operation,
settings: { ...settings, identifier: createVariable('pathParams') }
})
// Consumer-side library import.
this.register({
imports: { '@/components/data-table/data-table.tsx': ['DataTable'] }
})
}Two pulls (one for columns, one for the hook), one local Snippet, one library import. Same shape as the form, less coordination because a table is a simpler artifact.
Notable: the columns Projection uses { noExport: true }. That
suppresses the export prefix on the rendered Definition —
the columns array is a file-local helper that the table component
references directly, not part of the file's public API.
The field-renderer dispatch (inside FormFields)
The most interesting structural choice in gen-shadcn-form is
the property-by-property field dispatch in
schemaToField.ts:23-141. This is itself a small visitor:
export const schemaToField = ({ isRequired, schema, ... }) => {
if ('members' in schema && schema.members.length === 1) {
return schemaToField({ schema: schema.members[0], ... }) // unwrap intersection
}
if (schema.isRef()) {
return schemaToField({ schema: schema.resolve(), ... }) // recurse on resolved
}
if (schema.type === 'object') return new ObjectInput({ ... })
if (schema.type === 'array') return new Table({ ... })
if (schema.type === 'number') return new NumberInput({ ... })
if (schema.type === 'integer') return new IntegerInput({ ... })
if (schema.type === 'boolean') return new CheckboxInput({ ... })
if (schema.type === 'string') {
if (schema.enums?.length) return new SelectInput({ ..., enums })
}
return new StringInput({ ... })
}Each branch returns a registering Snippet (extends TsSnippet
from @skmtc/lang-typescript) whose
constructor registers its own consumer-side component import
(e.g., StringInput registers import { StringField } from '@/components/fields/string-field') and whose toString()
produces the JSX fragment for that field.
FormFields is itself a Snippet that, in its constructor, runs
schemaToField over every property of the request body and
stores the resulting Snippets in a List. FormFields.toString()
interpolates the list.
The dispatch is the customization seam most users hit first.
Cloning gen-shadcn-form to add a date picker, a rich-text
editor, or a file upload is a two-file change: a new Snippet
under src/fields/, plus a new branch in schemaToField.ts. See
the sibling recipe
custom-form-field-renderer.md
for the full clone-and-customize walkthrough.
Inter-generator coupling via enrichments
The most subtle coordination pattern in gen-shadcn-form happens
via enrichments, not via insert*. A user can supply a
references enrichment on a form field:
{
"enrichments": {
"@skmtc/gen-shadcn-form": {
"/contacts": {
"post": {
"main": {
"fields": [
{
"moduleSelect": { "schemaPath": ["officeIds"] },
"references": "GetOffices"
}
]
}
}
}
}
}
}When the form's field dispatch sees this enrichment, it doesn't
render an array-of-strings input. Instead, it looks up the
operation tagged GetOffices on the document and pulls in
@skmtc/gen-shadcn-select to render a searchable typeahead
backed by that operation
(schemaToField.ts:159-178).
This is inter-generator coupling by enrichment, not by import. The form generator doesn't know about specific operations or specific peer generators by name — it knows about a "reference" mechanism that lets the user declare "use the select generator against this operation tag." The select generator is a separate peer dependency, installed alongside.
The pattern generalizes: an enrichment that names an operation
becomes the routing key for a second, peer-generator-driven
artifact. Useful when the field schema doesn't itself encode the
typeahead source (the OAS doesn't say "the offices ID list comes
from GetOffices" — the user supplies that linkage via
enrichment).
Authoring your own multi-generator stack
The pattern, distilled:
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
- Concept: cross-generator coordination
- Concept: how generators produce output
- Concept: composing output with Stringable
- Concept: files, deduplication, and integrity
- Recipe: custom form field renderer — clone-and-customize variant
- How-to: compose with another generator — the narrower task-level guide
- How-to: swap a peer dependency