Projections and Snippets
The DSL's two-level model: named exportable artifacts (Projections) and anonymous embedded fragments (Snippets). Both descend from
SnippetBase. The distinguishing question is whether the unit of output needs a name at file scope.
A Projection produces an export const X = … declaration. A Snippet
produces a fragment that gets spliced into a Projection's body via
template-literal interpolation. The split makes file-scope identity a
deliberate choice, not a default.
The one-line distinction
A Projection has a name and a file. A Snippet has neither.
Projections produce export const NAME = VALUE; (or
export type NAME = …;) declarations. They're addressable, importable,
and participate in cross-generator coordination through the
(name, exportPath) cache. Snippets produce JSX elements, function
bodies, type fragments, or any other stringifiable value that gets
embedded into a Projection.
Why two levels?
The split maps onto JSX components vs JSX expressions. Top-level
React components have names, get imported by other modules, and exist
in their own files. JSX expressions (<Button onClick={...} />) are
anonymous and embedded in their parent's rendering. SKMTC's DSL makes
the same cut.
The alternative — a single "unit" abstraction — would force every
snippet to have a name and a path, even ones that exist only as an
inline <StringField /> JSX element. That's overhead with no benefit
to the cross-generator coordination story (which only needs file-scope
items in its cache). The two-level split keeps the cache small and the
authoring ergonomics natural.
Projections
The full-citizen class for unit-of-output:
- Subclasses one of three projection bases:
ModelProjectionBase,OasOperationProjectionBase, orGqlOperationProjectionBase - Has a class-level
id(the generator package name) - Has static methods on the class:
toIdentifier,toExportPath,toEnrichments,toEnrichmentSchema - Has instance properties:
settings: ContentSettings(= identifier + exportPath + enrichments), pluscontextandgeneratorKey - Has instance methods inherited from the base:
insertOperation,insertModel,insertNormalizedModel(these auto-filldestinationPathfromthis.settings.exportPath)
Drivers wrap a Projection's output value in a Definition, which
produces the export const NAME = VALUE; statement. The Projection's
toString() produces just the VALUE; the wrapping is automatic.
Examples in stock generators
ZodProjection(gen-zod) — producesexport const userBody = z.object({...})TsProjection(gen-typescript) — producesexport type UserBody = { ... }ShadcnForm(gen-shadcn-form) — producesexport const CreateUserForm = (props) => ...TanstackQuery(gen-tanstack-query-fetch-zod) — producesexport const useCreateUser = (args) => ...
Each is a class that extends an operation or model projection base.
Each has the static toIdentifier / toExportPath pair that makes
it addressable in the cache.
The three projection bases
| Base | Source unit | When |
|---|---|---|
ModelProjectionBase | An OAS schema component (a refName) | Generators that produce one file per type/schema |
OasOperationProjectionBase | An OAS operation (path + method) | Generators that produce one file per endpoint |
GqlOperationProjectionBase | A GraphQL operation | GraphQL-side generators (gen-reapit-graphql-client) |
Each base provides the insertOperation / insertModel /
insertNormalizedModel methods with destinationPath auto-filled
from this.settings.exportPath — a convenience over calling the
underlying methods on context directly.
Snippets
The anonymous fragment class:
- Extends
SnippetBasedirectly (or a subclass that doesn't introduce projection mechanics) - No required static methods
- No
settingsproperty - Receives constructor arguments like
destinationPathfrom the parent that will embed it - Embedded into a parent via
${this.snippet}template-literal interpolation - The parent calls
toString()on the snippet implicitly via the template literal
Examples in stock generators
FormFields(gen-shadcn-form) — a list of field renderings inside a formStringInput,SelectInput,CheckboxInput, etc. — individual JSX field elementsCustomValue— escape hatch wrapping arbitrary TS fragments that don't fit the OAS-derived schema modelIdentifier— a name with entity-type tracking (technically a Snippet, though rarely thought of that way)Definition— yes, even Definition is technically a Snippet (see below)
How Definition bridges them
Definition extends SnippetBase but plays a special bridging role:
it's the wrapper that makes a Projection's value exportable.
Projection.toString()
↓ produces VALUE (e.g., "z.object({...})")
Definition.toString()
↓ produces "export const NAME = VALUE;\n"
File.toString()
↓ produces the complete file with imports + definitionsSo Definition is the bridge between "unit of output" (Projection)
and "rendered TypeScript declaration." It's a Snippet in the sense
that it extends SnippetBase, but its role is specifically
projection-wrapping. You rarely construct one directly — Drivers do.
The composition model
JavaScript template literals call String() on interpolated values,
which falls back to .toString(). Every SnippetBase descendant
implements toString(), so they compose naturally:
class CreateUserForm extends ShadcnFormBase {
fields: FormFields // ← a Snippet
clientName: string
constructor(args) {
super(args)
this.fields = new FormFields(args)
this.clientName = this.insertOperation(TanstackQuery, args.operation).toName()
}
override toString() {
return `(props: CreateUserFormProps) => {
const form = useForm({...})
const mutator = ${this.clientName}()
return (
<Form {...form}>
<form onSubmit={...}>
${this.fields} // ← Snippet's toString() embedded here
<Button>Submit</Button>
</form>
</Form>
)
}`
}
}The interpolation pattern is the entire composition mechanism. No
registry, no slot system, no callback API. Anything that has a
toString() (which is everything that extends SnippetBase) can be
embedded.
When to use which
- Other generators might reference it by name → Projection
- It needs a file-scope export (
export const X = ...) → Projection - It's a fragment inside another generator's output → Snippet
- Unsure → Probably Snippet. Promote to Projection only when cross-file identity is needed.
The cost asymmetry: turning a Snippet into a Projection later is simple (subclass a base, add static methods). Turning a Projection into a Snippet is also simple but removes it from the cross-generator coordination cache. Default to Snippet unless you have a reason.
Choosing the right primitive — mechanical traps to avoid
Three shapes that look reasonable but break the framework's invariants
in specific ways. Each has the same root: reaching for an ad-hoc
construction when a Projection or SnippetBase descendant fits.
File-scope export via a Snippet + defineAndRegister
// ❌ A supplemental file-scope type, registered as a sibling Definition
// in the parent's exportPath, wrapping a Snippet instead of a Projection
class FormValuesSnippet extends SnippetBase {
override toString() { return `{ firstName: string; email: string }` }
}
context.defineAndRegister({
identifier: createType(`${name}Values`),
value: new FormValuesSnippet({ context }),
destinationPath: settings.exportPath
})This does land an export type CreateCustomerFormValues = {...} line
in the File. It also fails on three framework guarantees:
| Guarantee | Why it fails |
|---|---|
Cache-key identity is (Producer.toIdentifier(op), Producer.toExportPath(op)) | A Snippet has no static toIdentifier / toExportPath. The Definition is keyed by whatever name string the caller built. context.findDefinition({ name, exportPath }) works only for callers who know the exact name string — there's no Producer class to drive the lookup. |
insertOperation(Producer, op) requires a Projection class as the first arg | There is no class to pass. The Definition is unreachable through the operation-reference protocol; future generators that want this same type must re-derive the name string at their own call site. |
| Rename safety | The identifier name lives at the caller (${name}Values). Renaming the convention means editing every caller plus every consumer reading by that name. A Projection's toIdentifier is one site. |
Fix: extend the appropriate projection base. class FormValuesType extends MyFormBase { static override toIdentifier(...) {...} override toString() {...} }, then insertOperation(FormValuesType, op). The
mechanics now match the rest of cross-generator composition.
Ad-hoc { toString: () => '…' } returned from a helper function
// ❌ A helper that returns a duck-typed Stringable
const renderRow = (cols: number, inner: string) => ({
toString: () =>
`<div className="grid gap-4 sm:grid-cols-${cols}">${inner}</div>`
})This satisfies Stringable structurally and renders correctly when
interpolated. It also lacks every framework affordance a real Snippet
provides:
| Affordance | Why it fails |
|---|---|
context access (and therefore register({ imports, destinationPath })) | The object has no context. If the markup ever needs to import a peer component (a UI library <Row>, a hand-written helper), the import has to bubble up to whichever caller has context — coupling the caller to the helper's internals. |
generatorKey | The integrity machinery (affirmDefinition, findDefinition's mismatch detection) operates on values carrying a generatorKey. The duck-typed object has none, so it's invisible to the integrity layer if it ever ends up wrapped in a Definition. |
instanceof SnippetBase | Generic code that operates on "SnippetBase or its descendants" can't treat the duck-typed object as a member of the family. |
Fix: a real SnippetBase descendant with its register calls in the
constructor.
class FormRow extends SnippetBase {
constructor({ context, cols, inner, destinationPath }) {
super({ context })
this.cols = cols
this.inner = inner
// any imports the markup needs go here
}
override toString() {
return `<div className="grid gap-4 sm:grid-cols-${this.cols}">${this.inner}</div>`
}
}Same line count once the class is written; every framework guarantee restored.
Snippet over-parameterized with sole-caller hardcoded values
This one is not about the Projection-vs-Snippet choice — the Snippet is the right primitive, but its constructor signature is wider than necessary:
// ❌ Five constructor args; four are hardcoded by the sole caller
class FormFooter extends SnippetBase {
constructor({
context, destinationPath,
formIdVar, // always 'formId'
isSavingVar, // always 'isSaving'
cancelHandlerExpr,// always 'props.onCancel'
submitLabelExpr // genuinely varies
}) { ... }
}Mechanically: every parameter that all callers pass identically still
gets typed in the signature, passed at every call site, closed over by
the constructor, and read by toString(). The cost is in the type
surface and the call-site verbosity — not in framework invariants.
Diagnostic question per parameter: would a hypothetical second caller pass a different value here? If no → inline the value in the Snippet, remove the parameter.
This is adjacent to the two anti-patterns above (each is about reaching for the wrong shape) but the failure mode is API-design debt, not framework breakage. Listed here because the same triage question — "is this the right shape?" — catches all three.
Snippets need destinationPath passed in
Because Snippets don't have their own exportPath, they can't
register imports against their own file — they need to know where
their parent is going to land. The convention: the parent passes
destinationPath as a constructor argument.
class StringInput extends SnippetBase {
constructor({ context, name, destinationPath }) {
super({ context })
this.name = name
// Register against the parent's destination file
this.register({
imports: { '@/components/fields/string-field': ['StringField'] },
destinationPath
})
}
override toString() {
return `<StringField name="${this.name}" />`
}
}The parent Projection has its own exportPath from
this.settings.exportPath, so it doesn't need this dance. Snippets
nested inside a Projection (or another Snippet) follow the chain —
each parent passes destinationPath down to its children.
This is the "side-effect on context with explicit destination"
pattern: Snippets register their imports immediately during
construction, before the parent's toString() even runs. By the time
File.toString() assembles the final output, all imports across all
nested Snippets have already accumulated in the file's import map.
Common questions
Can a Snippet reach other generators?
Indirectly. A Snippet inside a Projection can call
this.context.insertOperation(...) or
this.context.insertNormalizedModel(...) directly (Snippets don't
have the projection-base wrappers, so they use the methods on
context). The returned Inserted<V, E> gives access to the peer's
identifier name just like in a Projection.
That said: most cross-generator coordination happens in Projection
constructors, where the projection-base wrappers auto-fill
destinationPath. Snippets that compose generators are unusual but
not forbidden.
Why isn't register on Projections only?
register is defined on SnippetBase because both layers need it.
Snippets register imports for themselves (against the parent's
file). Projections register imports and definitions for their target
file. The same primitive serves both — only destinationPath differs.
Is Definition really a Snippet?
By inheritance, yes. By role, no — it's a bridging wrapper that
Drivers create automatically. The fact that Definition extends SnippetBase is more of a technical detail than a conceptual claim.
In practice, you compose Projections (which become Definitions via
Drivers) with anonymous Snippets, and you never touch Definition
directly.
Can I have multiple Projections in one file?
Yes. Each Projection has its own Definition; the File holds a
Map<name, Definition>. Multiple Projections targeting the same
exportPath simply add their entries to the same File's
definitions map. The file's toString() joins them with blank
lines.
What if I want a named export but not a full Projection?
Use register({ definitions: [new TsDefinition({...})] }) directly.
This is the escape hatch — you produce a Definition without going
through a Projection base. The trade-off: no cross-generator
coordination (no cache, no integrity check) for that definition.
Useful when producing boilerplate that doesn't need to be addressable from other generators (e.g., a constants table, a default-values object).
Can a Snippet contain another Snippet?
Yes — composition is unlimited depth. A FormFields snippet contains
multiple StringInput / SelectInput snippets. Each child Snippet
embeds itself via ${this.fields} in the parent's toString().
Imports propagate up via shared destinationPath; the file's import
map accumulates contributions from every nested snippet.
Further reading
- How generators produce output — who instantiates Projections and when; the pull-based model
- Composing output with Stringable — how Projection and Snippet
toString()methods compose into rendered output - Cross-generator coordination — how Projections find each other
- The three phases — where Projections and Snippets fit in Parse / Generate / Render
- API reference: projection-bases
- API reference: dsl-snippet-base
- API reference: dsl-definition
skmtc-generatorskill — operational guidance for authoring