Skip to content

Codify what optional means and fix the all-optional internal types #776

Description

@InfinityBowman

32% of fields in the codebase are optional, and 27% of declarations with 3+ fields are at least 60% optional. Most of that is fine; some of it is under-declaration wearing a declaration's clothes.

Measurement

Across web, workers, shared, db, non-test source, declarations with 3 or more fields:

declarations measured:                    486
fields total:                           3,307
optional fields:                   1,056 (32%)
declarations >=60% optional:         132 (27%)

The rule to codify

Optional means "the caller may legitimately omit this," not "I am not sure." If the honest reason for a ? is that you do not know whether the field is there, the field is unvalidated and wants a parse, not a question mark. An all-optional type cannot fail, which is the same as having no type.

And ? is not | null. Drizzle left joins produce T | null, never T | undefined. Writing userAvatar?: string for a left-joined column claims the field may be missing when the truth is it is always present and may be null. This was a real source of confusion in PR #768, which needed member.userAvatar ?? undefined once the true type showed up.

Legitimate, leave alone

Options bags and props with defaults are correctly all-optional: ApiFetchOptions (9/9), EmbedPdfViewerProps (13/13), GoogleDrivePickerLauncherProps (10/10), ErrorHandlingOptions (5/5), PricingTableProps (4/4), EmailCodeFormProps (5/5).

Targets -- internal domain types

  • StudyMetadata (12/12) -- hooks/useAddStudies/deduplication.ts
  • Comparison / ComparisonDomain (7/7, 6/6) -- duplicated in both rob2-reconcile/navbar-utils.ts and robins-i-reconcile/navbar-utils.ts; the duplication is its own problem
  • DrizzleColumn (6/6) -- admin-database.server.ts, sits behind as unknown as Record<string, DrizzleColumn>
  • ROB2PreliminaryTemplate (4/4) -- primitives/useProject/handlers/rob2.ts
  • AnnotationData (5/5) -- components/checklist/ChecklistYjsWrapper.tsx
  • ReconciledMetadata (4/4) -- shared/src/checklists/amstar2/compare.ts

External shapes: out of scope for this issue

CrossRefWork (16/16) in lib/referenceLookup.ts and StripeEvent (12/12) in routes/api/auth/stripe/webhook.ts are all-optional because they describe APIs we do not control, and webhook.ts:151 / :200 do JSON.parse(rawBody) as StripeEvent.

Dropped from this issue on 2026-09-12. The webhook reads happen deliberately before signature verification, every field access is optional-chained and null-coalesced, and the values only feed the ledger row. A Zod schema there would fail loudly on a payload the handler already tolerates by design, for no gain. CrossRefWork is a similar story with lower stakes. If either ever feeds a decision rather than a log row, it wants a parse at that point.

Done when

  • The rule is written into .claude/CLAUDE.md
  • The internal types above declare what is actually guaranteed

Effort: a few hours, or incremental. Low risk.


Part of #778 (TypeScript correctness target state and tracker).

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

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions