QUESTION_TYPES is declared as Record<string, QuestionTypeEntry> in the generated .d.ts:
export declare const QUESTION_TYPES: Record<string, QuestionTypeEntry>;
Per-key literal types are erased in that annotation — TypeScript only sees a uniform QuestionTypeEntry, so any mapped-type consumer trying to derive a union of typeString literals collapses the conditional to never. The .js runtime carries the literals just fine (QUESTION_TYPES.select_one.typeString === "select_one"), but no consumer can read them statically.
What this blocks
The natural next step from #6 was a TypeScript-level derivation in consumers, so adding a type to the registry widens the consumer's QuestionType union automatically. That derivation doesn't work today:
// collapses to `never` because the conditional never matches the uniform QuestionTypeEntry
export type QuestionType = {
[K in keyof typeof QUESTION_TYPES]: (typeof QUESTION_TYPES)[K] extends {
kind: 'question'; typeString: infer T;
} ? T extends string ? T : never : never;
}[keyof typeof QUESTION_TYPES];
CorrelAid/formulaid's types.ts still hardcodes its 7-entry union as a workaround; the matching runtime Set in question_parser.ts is derived correctly. Adding time, select_one_from_file, select_multiple_from_file to the registry (already done) didn't reach the type system, so the runtime set silently disagrees with the literal union for those entries.
Proposed change
Emit QUESTION_TYPES with literal type preservation, while keeping the public shape compatible:
export const QUESTION_TYPES = {
grid: { id: "composite:grid", kind: "question", /* … */ },
select_one: { id: "type:select_one", kind: "question", typeString: "select_one", /* … */ },
// … full generated object, no Record<string, …> annotation
} as const satisfies Record<string, QuestionTypeEntry>;
Two effects:
(typeof QUESTION_TYPES)['select_one'] becomes { kind: 'question'; typeString: 'select_one'; … } instead of the uniform QuestionTypeEntry, so the mapped-type derivation above works.
Object.values(QUESTION_TYPES) becomes a tuple of literal entry types, not QuestionTypeEntry[]. Consumers iterating it need a runtime guard for missing typeString (composites) — same as today — but the type system can verify the shape.
The satisfies Record<string, QuestionTypeEntry> keeps the structural contract: every value must be assignable to QuestionTypeEntry. as const on top adds literal narrowing on the object itself.
Acceptance criteria
Suggested validation gate
716+ vitest (1 new — the literal assertion above), 33 pytest, 7 validate gates, drift guard. The npm run bless should produce no diff: this is a type-level change, not a runtime one. If a snapshot moves, that's evidence the .js payload changed too — wrong change.
QUESTION_TYPESis declared asRecord<string, QuestionTypeEntry>in the generated.d.ts:Per-key literal types are erased in that annotation — TypeScript only sees a uniform
QuestionTypeEntry, so any mapped-type consumer trying to derive a union oftypeStringliterals collapses the conditional tonever. The.jsruntime carries the literals just fine (QUESTION_TYPES.select_one.typeString === "select_one"), but no consumer can read them statically.What this blocks
The natural next step from #6 was a TypeScript-level derivation in consumers, so adding a type to the registry widens the consumer's
QuestionTypeunion automatically. That derivation doesn't work today:CorrelAid/formulaid'stypes.tsstill hardcodes its 7-entry union as a workaround; the matching runtime Set inquestion_parser.tsis derived correctly. Addingtime,select_one_from_file,select_multiple_from_fileto the registry (already done) didn't reach the type system, so the runtime set silently disagrees with the literal union for those entries.Proposed change
Emit
QUESTION_TYPESwith literal type preservation, while keeping the public shape compatible:Two effects:
(typeof QUESTION_TYPES)['select_one']becomes{ kind: 'question'; typeString: 'select_one'; … }instead of the uniformQuestionTypeEntry, so the mapped-type derivation above works.Object.values(QUESTION_TYPES)becomes a tuple of literal entry types, notQuestionTypeEntry[]. Consumers iterating it need a runtime guard for missingtypeString(composites) — same as today — but the type system can verify the shape.The
satisfies Record<string, QuestionTypeEntry>keeps the structural contract: every value must be assignable toQuestionTypeEntry.as conston top adds literal narrowing on the object itself.Acceptance criteria
dist/generated/QuestionTypes.d.tsshows per-key literaltypeString(e.g.select_one.typeString === "select_one", notstring)dist/generated/QuestionTypes.d.tsstill satisfiesRecord<string, QuestionTypeEntry>— no missing required fields, no extra required fields, no narrowing ofkind/isVariant/ etc. literals beyond what the registry actually saysregistryCatalogues.test.tscoverage test still passes (it iteratesObject.values(QUESTION_TYPES)and checks againstTYPE_MAPPINGS— works with the new shape)grid) still havetypeString?: undefinedso the consumer-side filterkind === 'question' && typeStringexcludes themexpect(QUESTION_TYPES.select_one.typeString).toBe('select_one')typed as'select_one'notstring. Fails the build if a future change accidentally re-widens the type.Suggested validation gate
716+ vitest (1 new — the literal assertion above), 33 pytest, 7 validate gates, drift guard. The
npm run blessshould produce no diff: this is a type-level change, not a runtime one. If a snapshot moves, that's evidence the .js payload changed too — wrong change.