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:
- Extends a projection base built by a factory: the language veneer's
toTsModelProjectionBase(config),toTsOasOperationProjectionBase(config), ortoTsGqlOperationProjectionBase(config)(from@skmtc/lang-typescript) returns the class the generator subclasses. Core's underlying factories (toModelProjectionBase(base, config)and siblings) do the assembly. - Has a class-level
id(the generator package name) - Has static methods on the class:
toIdentifierName,toIdentifierType,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 toIdentifierName / toExportPath pair that makes it addressable in the
cache.
The three projection-base factories
| Factory (lang veneer) | Source unit | When |
|---|---|---|
toTsModelProjectionBase | An OAS schema component (a refName) | Generators that produce one file per type/schema |
toTsOasOperationProjectionBase | An OAS operation (path + method) | Generators that produce one file per endpoint |
toTsGqlOperationProjectionBase | A GraphQL operation | GraphQL-side generators |
Each factory takes the generator's config (id, toIdentifierName,
toIdentifierType, toExportPath, toEnrichmentSchema, …) and returns the
class to extend — there is no subclassable ModelProjectionBase class to
import.
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 the language's snippet base directly (
TsSnippetfrom@skmtc/lang-typescript, which carriesregister) — or core's language-blindSnippetBasewhen it never registers anything - 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 }`;
}
}
defineAndRegister(context, {
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.toIdentifierName(op), Producer.toExportPath(op)) | A Snippet has no static toIdentifierName / 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 toIdentifierName is one site. |
Fix: extend the appropriate projection base.
class FormValuesType extends MyFormBase { static override toIdentifierName(...) {...} 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 snippet — extend the language's snippet base (TsSnippet from
@skmtc/lang-typescript, which carries register) with its register calls
in the constructor.
import { TsSnippet } from "@skmtc/lang-typescript";
class FormRow extends TsSnippet {
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.
import { TsSnippet } from "@skmtc/lang-typescript";
class StringInput extends TsSnippet {
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 lives on the language's snippet base — TsSnippet in
@skmtc/lang-typescript — because both layers need it and both are built on
that base: Snippets register imports for themselves (against the parent's
file), and Projections (whose bases are built on TsSnippet by the veneer
factories) register imports and definitions for their target file. Core's
SnippetBase is language-blind and has no register — register
ergonomics are typed by each language's concise vocabulary, which core can't
name. The same primitive serves both layers — 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
definitions map keyed by the identifier's declaration slot. Multiple
Projections targeting the same exportPath 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
- Anatomy of a generator — the authoring orientation
Generators as packages
SKMTC generators are JSR packages with a canonical internal layout. Understanding the package structure is the foundation for both using generators (install from JSR) and authoring or cloning them (where the source lives).
How generators produce output
Two facts that flip the default mental model from other codegen tools: `transform` is a fire-and-forget hook (its return value does not produce artifacts), and Projection classes are instantiated *on demand* by `insert*` calls (writing a Projection class does not, by itself, cause it to run). Output is produced by side-effect through `context.register` and `context.insert*`.