Part 08 — The entity-scaffolding quadruple: Repo + DTO + Mapper + Validator
The previous article walked the inputs — three decorator families, three shared scanners, the kind-tagged routing pattern. This article walks the four generators that turn each @Entity class into a quadruple of TypeScript files: a repository interface, a DTO triple, a mapper interface, and a validator interface. The four generators sit at lex positions 20, 30, 50, and 60 in the pipeline, with a dependency chain that exercises every cross-stage primitive walked in Parts 03–06: pure scans at stages 20 and 30 (no virtFS reads, emit at iteration 0), virtFS-reading stages 50 and 60 that depend on the partner files emitted earlier in the same iteration, and the cross-family dependency in stage 60 that links entity validators to FSM states.
After this article the reader has seen four generators front to back — roughly two hundred lines of source — and understands how each emitted file slots into the consumer's mental model of one entity = four artefacts. The acceptance criteria for this group of generators are bound in req-entity-scaffolding.ts and verified by test/unit/entity-scaffolding.test.ts.
Stage 20 — EntityRepositoryGenerator
Pure scan, no virtFS reads, emits at iteration 0. The full source is fifty lines in 20-entity-repository.ts. The execute method is the simplest in the pipeline:
async execute(ctx: GenerationContext): Promise<GenerationResult> {
const diagnostics: Diagnostic[] = [];
let emittedSomething = false;
for (const entity of scanEntities(ctx.project)) {
const body = renderRepository(entity);
const r = ctx.virtFs.addSource({
relPath: `${entity.className}.repository.generated.ts`,
contents: withBanner(body, { producerId: this.id }),
producerId: this.id,
});
if (r.newOrChanged) emittedSomething = true;
if (r.diagnostics) diagnostics.push(...r.diagnostics);
}
return { emittedSomething, diagnostics };
}async execute(ctx: GenerationContext): Promise<GenerationResult> {
const diagnostics: Diagnostic[] = [];
let emittedSomething = false;
for (const entity of scanEntities(ctx.project)) {
const body = renderRepository(entity);
const r = ctx.virtFs.addSource({
relPath: `${entity.className}.repository.generated.ts`,
contents: withBanner(body, { producerId: this.id }),
producerId: this.id,
});
if (r.newOrChanged) emittedSomething = true;
if (r.diagnostics) diagnostics.push(...r.diagnostics);
}
return { emittedSomething, diagnostics };
}The output for Order is six lines:
import type { Repository } from '../src/runtime/types.js';
import type { Order } from '../src/entities/order.entity.js';
import type { OrderKey } from './Order.dto.generated.js';
export interface OrderRepository extends Repository<Order, OrderKey> {}import type { Repository } from '../src/runtime/types.js';
import type { Order } from '../src/entities/order.entity.js';
import type { OrderKey } from './Order.dto.generated.js';
export interface OrderRepository extends Repository<Order, OrderKey> {}Three observations.
The first is the import from ./Order.dto.generated.js. The repository file references OrderKey, which is a type emitted by stage 30 (the DTO generator). At iteration 0, when stage 20 runs, stage 30 has not yet run; the imported file does not exist on virtFS or on disk. Why doesn't this fail to type-check?
The answer is that the generators are emitting source code, not running it. The repository file's body is a string; the engine writes that string to virtFS. Subsequent tsc runs over the project — after the pipeline commits — will resolve the imports. By that time stage 30 has emitted Order.dto.generated.ts with the OrderKey declaration, and the import resolves. Type-checking happens after the fixpoint commit, not during the pipeline run. The stage 20 generator does not need to wait for stage 30; it can confidently emit a forward reference to a file it knows another stage will produce.
The second is the absence of any virtFS read. The generator does not call ctx.virtFs.has(...) or read(...); it does not depend on any file produced by another stage. It runs once at iteration 0, emits one file per entity, and is byte-stable across all subsequent iterations (idempotent, returns newOrChanged: false from the second iteration onward).
The third is the deterministic per-entity output. The scanner sorts entities alphabetically; the loop visits them in that order; the emitted files have content determined entirely by the ScannedEntity record. Five runs on identical inputs produce five byte-identical Order.repository.generated.ts files — the property tested by fiveRunsByteIdentical() in Part 05.
Stage 30 — EntityDtoGenerator and the type-level emission
Stage 30 is the most type-rich generator in the pipeline. Per @Entity, it emits three type aliases: Create, Update, Output. The render function is in 30-entity-dto.ts:42-65:
return [
`import type { DistributiveOmit, Branded, EntityId } from '../src/runtime/types.js';`,
`import type { ${className} } from '${entitySourceImport(entity)}';`,
``,
`export type ${className}Key = EntityId<'${kind}'>;`,
``,
`export type ${className}CreateInput = Branded<`,
` DistributiveOmit<${className}, ${pkUnion}>,`,
` '${className}CreateInput'`,
`>;`,
``,
`export type ${className}UpdateInput = Partial<DistributiveOmit<${className}, ${pkUnion}>>;`,
``,
`export type ${className}Output = Omit<${className}, ${pkUnion}> & { readonly ${pkProperty}: ${className}Key };`,
``,
].join('\n');return [
`import type { DistributiveOmit, Branded, EntityId } from '../src/runtime/types.js';`,
`import type { ${className} } from '${entitySourceImport(entity)}';`,
``,
`export type ${className}Key = EntityId<'${kind}'>;`,
``,
`export type ${className}CreateInput = Branded<`,
` DistributiveOmit<${className}, ${pkUnion}>,`,
` '${className}CreateInput'`,
`>;`,
``,
`export type ${className}UpdateInput = Partial<DistributiveOmit<${className}, ${pkUnion}>>;`,
``,
`export type ${className}Output = Omit<${className}, ${pkUnion}> & { readonly ${pkProperty}: ${className}Key };`,
``,
].join('\n');The output for Order (whose primary key field is id) materialises the three patterns:
export type OrderKey = EntityId<'order'>;
export type OrderCreateInput = Branded<
DistributiveOmit<Order, 'id'>,
'OrderCreateInput'
>;
export type OrderUpdateInput = Partial<DistributiveOmit<Order, 'id'>>;
export type OrderOutput = Omit<Order, 'id'> & { readonly id: OrderKey };export type OrderKey = EntityId<'order'>;
export type OrderCreateInput = Branded<
DistributiveOmit<Order, 'id'>,
'OrderCreateInput'
>;
export type OrderUpdateInput = Partial<DistributiveOmit<Order, 'id'>>;
export type OrderOutput = Omit<Order, 'id'> & { readonly id: OrderKey };Each line is a deliberate use of a type-level pattern. The patterns are worth walking individually because they are the part of the example most easily missed on a casual read.
EntityKey — branded template-literal type
OrderKey = EntityId<'order'> expands (via the runtime types in runtime/types.ts:9) to:
type EntityId<K extends string> = Branded<`${K}_${string}`, K>;
type Branded<T, B extends string> = T & { readonly __brand: B };type EntityId<K extends string> = Branded<`${K}_${string}`, K>;
type Branded<T, B extends string> = T & { readonly __brand: B };Substituting: OrderKey is the type of strings shaped like `order_${string}` (a template literal type), branded by the literal 'order'. A value 'order_42' typechecks as OrderKey; a value '42' does not (no order_ prefix); a value 'user_42' does not (wrong prefix). The branding makes OrderKey nominally distinct from UserKey — even though both are structurally string, the __brand phantom property makes the compiler refuse to substitute one for the other.
This is the canonical TS pattern for nominal IDs over a structural type system. It costs zero at runtime (the __brand property is purely type-level — values do not actually carry it) and gives the consumer compile-time safety against ID confusion.
CreateInput — branded DistributiveOmit of the entity
OrderCreateInput is Branded<DistributiveOmit<Order, 'id'>, 'OrderCreateInput'>. The intent is "an Order minus its primary key, and this type is a Create-input, not an Update-input or an Output". The DistributiveOmit (from runtime/types.ts:7) is the distribution-safe variant of Omit. Without it, Omit<A | B, K> collapses to Omit<A | B, K> — TS does not distribute Omit over union types by default. The custom DistributiveOmit<T, K> = T extends unknown ? Omit<T, K> : never exploits the conditional-type distribution rule to do it correctly.
For Order, which is not a union, the difference is invisible. For an entity whose type happened to be a union (rare in practice — generally entity classes are not union-typed — but possible if a future @Entity allowed alternation), the distribution matters. The DTO generator uses DistributiveOmit unconditionally because the cost is zero and the safety is real.
UpdateInput — Partial<DistributiveOmit<...>>
OrderUpdateInput is Partial<DistributiveOmit<Order, 'id'>>. The intent is "any subset of the non-PK fields can be present, all are optional". The Partial is standard TypeScript; the composition with DistributiveOmit is the same pattern as CreateInput. The shape is what a PATCH endpoint or an Update form would consume.
Output — Omit plus PK override
OrderOutput is Omit<Order, 'id'> & { readonly id: OrderKey }. The intent is "an Order shape, but the id property is now an OrderKey (the branded ID), not the original string declared in the entity". The intersection swaps the PK type. A consumer reading an OrderOutput from the repository gets a value whose id is typed as OrderKey, so subsequent code that passes the id to another repository will be type-checked.
The four type aliases together form the consumer's contract surface. A consumer never imports Order directly to talk to the repository; it imports OrderCreateInput, OrderUpdateInput, OrderOutput, OrderKey. The entity class is the internal representation; the DTOs are the boundary.
Stage 50 — EntityMapperGenerator (cross-stage virtFS read)
Stage 50 is the first generator in this group that reads from virtFS. Per @Entity, it emits a mapper interface that depends on the DTO generated by stage 30. The execute method is short (50-entity-mapper.ts:20-39):
async execute(ctx: GenerationContext): Promise<GenerationResult> {
const diagnostics: Diagnostic[] = [];
let emittedSomething = false;
for (const entity of scanEntities(ctx.project)) {
const dtoRel = `${entity.className}.dto.generated.ts`;
if (!ctx.virtFs.has(dtoRel)) continue;
const body = renderMapper(entity);
const r = ctx.virtFs.addSource({
relPath: `${entity.className}.mapper.generated.ts`,
contents: withBanner(body, { producerId: this.id }),
producerId: this.id,
});
if (r.newOrChanged) emittedSomething = true;
if (r.diagnostics) diagnostics.push(...r.diagnostics);
}
return { emittedSomething, diagnostics };
}async execute(ctx: GenerationContext): Promise<GenerationResult> {
const diagnostics: Diagnostic[] = [];
let emittedSomething = false;
for (const entity of scanEntities(ctx.project)) {
const dtoRel = `${entity.className}.dto.generated.ts`;
if (!ctx.virtFs.has(dtoRel)) continue;
const body = renderMapper(entity);
const r = ctx.virtFs.addSource({
relPath: `${entity.className}.mapper.generated.ts`,
contents: withBanner(body, { producerId: this.id }),
producerId: this.id,
});
if (r.newOrChanged) emittedSomething = true;
if (r.diagnostics) diagnostics.push(...r.diagnostics);
}
return { emittedSomething, diagnostics };
}The if (!ctx.virtFs.has(dtoRel)) continue is the load-bearing line. It says: if the DTO file for this entity is not yet in virtFS, skip this entity for now. At iteration 0, the runner invokes generators in lex order — stage 30 runs before stage 50 — so by the time stage 50 looks for Order.dto.generated.ts, it is already in virtFS. The check passes; the mapper is emitted in the same iteration.
The defensive if exists for a slightly different scenario. If a future revision of stage 30 decided to skip a particular entity (because of a missing required configuration, say), stage 50's has check would fail, the mapper for that entity would not be emitted, and the run would converge cleanly without trying to import a nonexistent file. The guard prevents stage 50 from emitting a mapper that imports a non-existent DTO — which would type-check cleanly during pipeline execution (the DTO file would exist in virtFS or not at all) but would fail to type-check on disk after commit.
The output for Order:
import type { Mapper } from '../src/runtime/types.js';
import type { Order } from '../src/entities/order.entity.js';
import type { OrderOutput, OrderCreateInput } from './Order.dto.generated.js';
export interface OrderMapper extends Mapper<Order, OrderOutput> {
fromCreateInput(input: OrderCreateInput): Order;
}import type { Mapper } from '../src/runtime/types.js';
import type { Order } from '../src/entities/order.entity.js';
import type { OrderOutput, OrderCreateInput } from './Order.dto.generated.js';
export interface OrderMapper extends Mapper<Order, OrderOutput> {
fromCreateInput(input: OrderCreateInput): Order;
}The mapper interface references three types from the DTO file (OrderOutput, OrderCreateInput, plus the inherited Mapper<E, DTO> from runtime/types.ts). The implementation is left to the consumer; the generator emits the interface — what the mapper looks like — not the implementation — what the mapper does. A consumer writes class OrderMapperImpl implements OrderMapper { /* ... */ } and the type-checker enforces the contract.
Stage 60 — EntityValidatorGenerator (cross-family virtFS read)
Stage 60 is the most elaborate generator in the entity quadruple. It reads two cross-stage dependencies — the DTO file (same family, stage 30) and the FSM states file (cross-family, stage 40) — and surfaces info-severity diagnostics for dangling references. The generator's full source is in 60-entity-validator.ts.
The dependency graph for the validator is:
- Per entity, the DTO must be in virtFS (
if (!ctx.virtFs.has(dtoRel)) continue). - Per entity, every FSM that the entity references via
@Entity({ lifecycle })or@Field({ stateOf })should exist in the project. If not, an info-severitySG-DANGLING-LIFECYCLEorSG-DANGLING-STATEOFdiagnostic is surfaced — but the run keeps going; the dangling field falls back to its plain field type. - Per entity, every FSM that does exist in the project must have its
<Class>.states.generated.tsin virtFS. If not, the generator returns early for this entity withreturn { emittedSomething, diagnostics }(note: the early return aborts the whole call, not just this entity — a deliberate conservative choice, the validator re-runs in a subsequent iteration when the FSM states have landed).
The dangling-reference handling is worth reading in full (60-entity-validator.ts:42-70):
if (typeof entity.args.lifecycle === 'string' && !knownFsmRefs.has(entity.args.lifecycle)) {
diagnostics.push({
severity: 'info',
code: 'SG-DANGLING-LIFECYCLE',
message: `DanglingLifecycle: @Entity '${entity.className}' references missing FSM '${entity.args.lifecycle}'`,
producerId: this.id,
file: entity.sourceFilePath,
});
}
const allFsmDeps = collectFsmDeps(entity);
const liveFsmDeps: FsmDep[] = [];
for (const dep of allFsmDeps) {
if (knownFsmRefs.has(dep.fsmClassName)) {
if (!ctx.virtFs.has(dep.statesRel)) {
// FSM exists but its states module hasn't landed yet — wait next iter.
return { emittedSomething, diagnostics };
}
liveFsmDeps.push(dep);
} else {
diagnostics.push({
severity: 'info',
code: 'SG-DANGLING-STATEOF',
message: `DanglingStateOf: @Field stateOf references missing FSM '${dep.fsmClassName}' on entity '${entity.className}'`,
producerId: this.id,
file: entity.sourceFilePath,
});
}
}if (typeof entity.args.lifecycle === 'string' && !knownFsmRefs.has(entity.args.lifecycle)) {
diagnostics.push({
severity: 'info',
code: 'SG-DANGLING-LIFECYCLE',
message: `DanglingLifecycle: @Entity '${entity.className}' references missing FSM '${entity.args.lifecycle}'`,
producerId: this.id,
file: entity.sourceFilePath,
});
}
const allFsmDeps = collectFsmDeps(entity);
const liveFsmDeps: FsmDep[] = [];
for (const dep of allFsmDeps) {
if (knownFsmRefs.has(dep.fsmClassName)) {
if (!ctx.virtFs.has(dep.statesRel)) {
// FSM exists but its states module hasn't landed yet — wait next iter.
return { emittedSomething, diagnostics };
}
liveFsmDeps.push(dep);
} else {
diagnostics.push({
severity: 'info',
code: 'SG-DANGLING-STATEOF',
message: `DanglingStateOf: @Field stateOf references missing FSM '${dep.fsmClassName}' on entity '${entity.className}'`,
producerId: this.id,
file: entity.sourceFilePath,
});
}
}The trichotomy here is important. There are three states a referenced FSM can be in.
State A — FSM exists, states module is in virtFS. Use it. The dependency is live and gets added to liveFsmDeps.
State B — FSM exists, states module is not yet in virtFS. Wait. The early return defers the validator's emission to the next iteration, when stage 40 has had a chance to emit its states file.
State C — FSM does not exist in the project at all (dangling reference). Surface an info diagnostic, fall back to the plain field type. The generator does not block on a dangling reference; the entity's validator is still emitted, the dangling field's type defaults to the literal type (string for a type: 'string' field, etc.).
The choice to make dangling references info-severity, not error, is intentional. A dangling reference is usually a transient state during refactoring — the user renamed an FSM and forgot to update the entity's lifecycle reference. Failing the entire run on this would slow down development; surfacing an info-severity diagnostic and continuing lets the user see the dangling reference in the next CI build but not block on it.
The output for the Order validator is:
import type { Validator, EntityId } from '../src/runtime/types.js';
import type { OrderState } from './OrderFsm.states.generated.js';
export interface OrderValidated {
readonly id: EntityId<'order'>;
readonly userId: EntityId<'user'>;
readonly total: number;
readonly status: OrderState;
}
export interface OrderValidator extends Validator<OrderValidated> {}import type { Validator, EntityId } from '../src/runtime/types.js';
import type { OrderState } from './OrderFsm.states.generated.js';
export interface OrderValidated {
readonly id: EntityId<'order'>;
readonly userId: EntityId<'user'>;
readonly total: number;
readonly status: OrderState;
}
export interface OrderValidator extends Validator<OrderValidated> {}Three observations.
The first is the per-field type narrowing. The status field on Order is declared as string in the entity class but typed as OrderState (the FSM literal-union) in the validator output. The narrowing happens because @Field({ stateOf: 'OrderFsm' }) is on that field, and the validator generator emits the FSM-derived type instead of the plain field type. The propertyTypeText helper (60-entity-validator.ts:137-155) implements the dispatch: PK fields get EntityId<'<kind>'>, ref fields get EntityId<'<refKind>'>, stateOf fields get <Prefix>State, everything else gets the plain JS type.
The second is the cross-family import. import type { OrderState } from './OrderFsm.states.generated.js' references a file emitted by stage 40 (the FSM-states generator). The validator (stage 60) reads it from virtFS via ctx.virtFs.has(dep.statesRel) and writes the import. This is the example's most direct cross-family dependency: an entity-family generator reading an FSM-family generator's output.
The third is the absence of an implementation. As with the mapper, the generator emits the interface (OrderValidator extends Validator<OrderValidated>), not the implementation. A consumer writes class OrderValidatorImpl implements OrderValidator { parse(input) { /* zod, valibot, hand-rolled */ } }. The generator's job is to declare what the validator must produce; the consumer's job is how.
The dependency graph as a whole
Putting the four stages together:
┌──────────────────────┐
│ scanEntities(project)│
└──────────────────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌───────────────┐ ┌──────────┐ ┌──────────────┐
│ 20-repository │ │ 30-dto │ │ (40-fsm.states)
└───────────────┘ └──────────┘ └──────────────┘
│ │
▼ │
┌──────────┐ │
│ 50-mapper│ │
└──────────┘ │
│ │
└──────┬───────┘
▼
┌──────────────┐
│ 60-validator │
└──────────────┘ ┌──────────────────────┐
│ scanEntities(project)│
└──────────────────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌───────────────┐ ┌──────────┐ ┌──────────────┐
│ 20-repository │ │ 30-dto │ │ (40-fsm.states)
└───────────────┘ └──────────┘ └──────────────┘
│ │
▼ │
┌──────────┐ │
│ 50-mapper│ │
└──────────┘ │
│ │
└──────┬───────┘
▼
┌──────────────┐
│ 60-validator │
└──────────────┘Stages 20 and 30 are forward-only; they run at iteration 0 and emit. Stage 50 reads stage 30's DTO (same family, same iteration). Stage 60 reads stage 30's DTO and stage 40's FSM states (cross-family, same iteration). Both stages 50 and 60 run at iteration 0 in the canonical example because their dependencies — stages 30 and 40 — also emit at iteration 0, and the lex ordering puts dependencies before consumers.
A reader who wants to confirm this can re-read Part 03: the iteration-0 emission set is every stage that doesn't have an incomplete virtFS dependency. Stages 20, 30, 40 have no virtFS dependencies (pure scans). Stages 50 and 60 have virtFS dependencies on stages 30 and 40, both of which lex-order before them and emit before they execute. Stages 70, 80, 99 have virtFS dependencies that are also satisfied by lex order. Only stage 0 (registry) has a backward edge — its dependency (stage 20) lex-orders after it — and that is why stage 0 alone fails to emit at iteration 0 and emits at iteration 1 instead.
The acceptance criteria for the entity-scaffolding group are bound in req-entity-scaffolding.ts. The Feature class entity-scaffolding.feature.ts declares acceptance criteria methods including quadrupleEmittedPerEntity() (each entity gets four .generated.ts files) and dtoTypesUseDistributiveOmitAndBranded() (the DTO triple uses the canonical patterns).
Bridge
Part 09 walks the FSM-family generators (stages 40 and 70). The states module emitted by stage 40 is what stage 60's validator reads in this article; Part 09 explains what is in that states module and how the dispatcher generator turns it into a typed dispatch function. After Part 09, the cross-family dependency walked above is no longer a black box.
The Feature for this article is FEAT-TSGEN-08 in assets/features.ts. Acceptance criteria: four generators walked and dependency chain shown; DistributiveOmit + Branded + EntityId emissions explained; cross-stage visibility via virtFS demonstrated; SGE-ENTITY-SCAFFOLDING acs anchored. Each section above maps to one of those ACs.