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.
Summary
updateChecklistAnswer(studyId, checklistId, key, data: Record<string, unknown>)is untyped at the handler boundary. Each checklist handler reconstructs the expected shape ofdatabased onkey. Replace with per-checklist Zod schemas + typedupdateAnswer<K>()signatures. Land incrementally, one checklist type at a time.Problem
packages/web/src/primitives/useProject/checklists/index.ts:246-276:Consequences:
{ answers: [[true, false]], critical: true }for AMSTAR2 Q1 but TS cannot verify the shape matches what the handler expects.{ answers: "oops" }lands silently in the Y.Doc and may crash rendering.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:Handler signature becomes:
updateChecklistAnswerbecomes generic over checklist type. Runtime Zod parse at the boundary (cheap) catches shape mistakes that survive casts.Incremental rollout
Record<string, unknown>fallbackEach PR is self-contained, roughly a day of work.
Non-goals
Relationship to other issues
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.