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).
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: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 produceT | null, neverT | undefined. WritinguserAvatar?: stringfor 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 neededmember.userAvatar ?? undefinedonce 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.tsComparison/ComparisonDomain(7/7, 6/6) -- duplicated in bothrob2-reconcile/navbar-utils.tsandrobins-i-reconcile/navbar-utils.ts; the duplication is its own problemDrizzleColumn(6/6) --admin-database.server.ts, sits behindas unknown as Record<string, DrizzleColumn>ROB2PreliminaryTemplate(4/4) --primitives/useProject/handlers/rob2.tsAnnotationData(5/5) --components/checklist/ChecklistYjsWrapper.tsxReconciledMetadata(4/4) --shared/src/checklists/amstar2/compare.tsExternal shapes: out of scope for this issue
CrossRefWork(16/16) inlib/referenceLookup.tsandStripeEvent(12/12) inroutes/api/auth/stripe/webhook.tsare all-optional because they describe APIs we do not control, andwebhook.ts:151/:200doJSON.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.
CrossRefWorkis 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
.claude/CLAUDE.mdEffort: a few hours, or incremental. Low risk.
Part of #778 (TypeScript correctness target state and tracker).