Skip to content

Refactor: Replace Record<string, unknown> answer payloads with typed per-checklist schemas #482

@InfinityBowman

Description

@InfinityBowman

Summary

updateChecklistAnswer(studyId, checklistId, key, data: Record<string, unknown>) is untyped at the handler boundary. Each checklist handler reconstructs the expected shape of data based on key. Replace with per-checklist Zod schemas + typed updateAnswer<K>() signatures. Land incrementally, one checklist type at a time.

Problem

packages/web/src/primitives/useProject/checklists/index.ts:246-276:

function updateChecklistAnswer(
  studyId: string,
  checklistId: string,
  key: string,
  data: Record<string, unknown>,
): void {
  // ...
  const handler = getHandler(checklistType);
  if (handler) {
    handler.updateAnswer(answersMap, key, data);
  }
}

Consequences:

  • Component passes { answers: [[true, false]], critical: true } for AMSTAR2 Q1 but TS cannot verify the shape matches what the handler expects.
  • No schema validation — a bug producing { answers: "oops" } lands silently in the Y.Doc and may crash rendering.
  • Refactoring a question's shape requires manual grep across callers; no compiler help.

AMSTAR2 is the most irregular (boolean matrices per question, varying grid sizes); ROBINS-I and RoB2 are enum-based and more regular.

Proposed solution

Define typed answer schemas per checklist in packages/shared/src/checklists/*/schema.ts:

// AMSTAR2
export const Amstar2QuestionAnswer = z.object({
  answers: z.array(z.array(z.boolean())),
  critical: z.boolean().optional(),
});

export const Amstar2Answers = z.object({
  q1: Amstar2QuestionAnswer,
  // ... q2 through q16
});

export type Amstar2Answers = z.infer<typeof Amstar2Answers>;
export type Amstar2Key = keyof Amstar2Answers;
export type Amstar2AnswerFor<K extends Amstar2Key> = Amstar2Answers[K];

Handler signature becomes:

updateAnswer<K extends Amstar2Key>(
  answersMap: Y.Map<unknown>,
  key: K,
  data: Amstar2AnswerFor<K>,
): void

updateChecklistAnswer becomes generic over checklist type. Runtime Zod parse at the boundary (cheap) catches shape mistakes that survive casts.

Incremental rollout

  • PR 1: AMSTAR2 schema + typed handler + updated callers (hardest case first, surfaces the pattern)
  • PR 2: ROBINS-I
  • PR 3: RoB2
  • PR 4: Remove the generic Record<string, unknown> fallback

Each PR is self-contained, roughly a day of work.

Non-goals

  • Not changing Y.Map storage shape.
  • Not introducing a new abstraction layer.
  • Not replacing existing descriptive schemas used for rendering — those can coexist with the answer-payload schemas, or merge into one file per checklist.

Relationship to other issues

  • Synergistic with the typed project.* API refactor (companion issue): the singleton provides call-site types, the schemas provide payload types. Neither blocks the other, but together they deliver end-to-end type safety.
  • Easiest to land after the unify-shapes issue, so the typed handler only needs to serve one code path.
  • Independent of the subscription-hook issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions