Skip to content

Emit QUESTION_TYPES with as const so per-key typeStrings survive into the .d.ts #9

Description

@jstet

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:

  1. (typeof QUESTION_TYPES)['select_one'] becomes { kind: 'question'; typeString: 'select_one'; … } instead of the uniform QuestionTypeEntry, so the mapped-type derivation above works.
  2. 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

  • dist/generated/QuestionTypes.d.ts shows per-key literal typeString (e.g. select_one.typeString === "select_one", not string)
  • dist/generated/QuestionTypes.d.ts still satisfies Record<string, QuestionTypeEntry> — no missing required fields, no extra required fields, no narrowing of kind / isVariant / etc. literals beyond what the registry actually says
  • The registryCatalogues.test.ts coverage test still passes (it iterates Object.values(QUESTION_TYPES) and checks against TYPE_MAPPINGS — works with the new shape)
  • Composites (grid) still have typeString?: undefined so the consumer-side filter kind === 'question' && typeString excludes them
  • One consumer-side test in this repo's suite asserts a specific literal: expect(QUESTION_TYPES.select_one.typeString).toBe('select_one') typed as 'select_one' not string. 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 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions