fix(firestore)!: narrow composed Optional/OptionalNull json Encoded to reject null - #119
Conversation
…o reject null The hand-written conditional return type for composed `Optional(Field)` and `OptionalNull(Field)` mapped the `json` variant to `Schema.OptionFromOptionalNullOr` (admitting `null` in `Encoded`), while the runtime `Model.fieldEvolve` table maps `json` to `Schema.OptionFromOptional` (rejecting `null` with `SchemaError`). The conditional's `json.Encoded` was therefore wider than the runtime decoder in the `null` dimension: a value that `typeof Model.json.Encoded` accepted compiled but threw at runtime. Carve `json` out of the non-database branch in both conditionals so it maps to `Schema.OptionFromOptional`, matching the runtime and mirroring `OptionalDeletable`'s existing conditional. The runtime tables are unchanged; `jsonCreate`/`jsonUpdate` and the database variants keep `OptionFromOptionalNullOr`/`OptionFromNullOr`. BREAKING CHANGE: the static `json` `Encoded` type for composed `Optional(Field)` / `OptionalNull(Field)` (e.g. `Optional(DateTime)`, `Optional(Reference(...))`) no longer admits `null` for the optional field. Code that annotated a `null` literal against `typeof Model.json.Encoded` for such a field compiled before but threw `SchemaError` at runtime; the fix turns that runtime failure into a compile-time error. The simple `Optional(Schema)` / `OptionalNull(Schema)` forms, `OptionalDeletable`, the encoding direction, and `jsonCreate` / `jsonUpdate` Encoded types are unaffected.
|
| _: typeof OptionalDeletableRefModel.json.Encoded, | ||
| ) => _; | ||
| const expectJsonDate = (_: typeof OptionalDateModel.json.Encoded) => _; | ||
| const expectJsonNullDate = (_: typeof OptionalNullDateModel.json.Encoded) => | ||
| _; | ||
|
|
||
| it('rejects null at the type level (all directives must be consumed)', () => { | ||
| // @ts-expect-error null is not in Optional(Reference).json Encoded | ||
| const ref = expectJsonRef({ name: 'x', author: null }); | ||
| // @ts-expect-error null is not in OptionalNull(Reference).json Encoded | ||
| const nullRef = expectJsonNullRef({ name: 'x', author: null }); | ||
| // @ts-expect-error null is not in OptionalDeletable(Reference).json Encoded | ||
| const deletableRef = expectJsonDeletableRef({ name: 'x', author: null }); |
There was a problem hiding this comment.
The new @ts-expect-error assertions are not checked by the committed CI path. CI runs lint, test, and build; Vitest transpiles these specs without type-checking them, and the build configuration excludes *.spec.ts. Reverting the conditional-type fix would therefore leave these directives unused without failing CI, so this regression guard is ineffective. Please add a working type-check target to CI or move the assertions to a type-test surface that CI compiles.
There was a problem hiding this comment.
Confirmed the guard is unenforced as you noted, but the fix is a CI/build-config change outside this PR's diff, so I'm leaving it for a maintainer to decide.
What I checked:
- CI runs
nx affected -t lint test buildonly (.github/workflows/ci.yml:51); notypecheck. buildusestsconfig.lib.json, which excludessrc/**/*.spec.ts(tsconfig.lib.json:14-27), so this spec isn't compiled bybuild.testrunsvitest, which transpiles specs without type-checking, so the@ts-expect-errordirectives are not validated.- The only existing type-check path is the
typechecktarget, and it is already broken:nx run effect-firebase:typecheckfails with 29TS6307errors becausetsconfig.spec.json's composite project doesn't list the lib sources it imports (viarepository.spec.ts,query.spec.ts,array.spec.ts,datetime.spec.ts— none of which are changed by this PR; thetsconfig*.jsonfiles are unchanged vsmain). That looks like exactly whytypecheckisn't wired into CI.
Enforcing the guard therefore needs either (a) fixing tsconfig.spec.json's project setup and adding typecheck to ci.yml, or (b) enabling vitest typecheck in vite.config.mts — all files outside this PR's diff. The runtime regression tests in this spec (the "json runtime rejects null" block) are already enforced by the test target; only the compile-time @ts-expect-error directives aren't. A human should decide whether to take the infra change in this PR or a separate one.
Detail bug report: View on Detail
Closes #100
Bug
Firestore.Optional(field)andFirestore.OptionalNull(field)(when wrapped around a multi-variantFieldsuch asDateTimeorReference(id, path)) had a hand-written conditional return type whosejsonvariant resolved toSchema.OptionFromOptionalNullOr— admittingnullintypeof Model.json.Encoded. The runtimeModel.fieldEvolvetable that the helpers actually build thejsonvariant with isSchema.OptionFromOptional, which rejectsnullwith aSchemaError.So a
nullvalue for such a field compiled againsttypeof Model.json.Encodedbut threw at runtime — the type was wider than the decoder. The simpleOptional(Schema)/OptionalNull(Schema)forms andOptionalDeletablewere already correct; this only affected the composedOptional(Field)/OptionalNull(Field)branch of the conditional.Fix
Type-only change in
packages/effect-firebase/src/lib/firestore/model/optional.ts: carvejsonout of the non-database branch of both conditionals so it maps toSchema.OptionFromOptional<S[K]>, matching the runtime and mirroringOptionalDeletable's existing conditional. The runtimefieldEvolvetables are byte-identical to before — no runtime behavior changes.jsonCreate/jsonUpdateand the database variants keep their existingOptionFromOptionalNullOr/OptionFromNullOrmappings.This is a type-level breaking change for the published
effect-firebasepackage: thejsonEncodedtype for composedOptional(Field)/OptionalNull(Field)no longer admitsnull. The only affected code is the (undocumented) pattern of annotating anullliteral againsttypeof Model.json.Encodedfor such a field — which compiled before but already threwSchemaErrorat runtime, so the fix converts a runtime failure into a compile-time error. The encoding direction,jsonCreate/jsonUpdateEncodedtypes, the simple forms, andOptionalDeletableare unaffected. Documented inMIGRATION.mdper the repo's breaking-change convention, and flagged with aBREAKING CHANGEcommit footer for the release notes.Testing
composed json null-admissionblock tooptional.spec.tsusing typed identity-function helpers with@ts-expect-errordirectives (the repo's existing type-assertion pattern, e.g.query.spec.ts,repository.spec.ts). The block covers all five composed forms across both a 6-variantField(Reference) and a 4-variantField(DateTime), withOptionalDeletableas a control. With the fix applied the directives are all consumed; reverting the fix produces exactly 4×TS2578 "Unused '@ts-expect-error' directive"at the bug-affected forms while theOptionalDeletablecontrol directive stays consumed — i.e. the test fails to compile iff the divergence returns. Positive no-regression checks assertjsonCreate/jsonUpdateEncodedtypes still admitnullat both the type and runtime levels.Schema.decodeUnknownSync(<Model>.json)({ name: 'x', <field>: null })throws for all five composed forms (unchanged by the type-only fix), andjsonCreate/jsonUpdatestill decodenulltoOption.none().effect-firebasevitest suite (308 tests across 13 spec files), an isolatedtsc --noEmittypecheck of the changed module and its in-package dependencies (exit 0, all directives consumed), eslint (0 errors; the 6no-explicit-anywarnings are pre-existingas anycasts untouched by the fix), prettier, andpnpm nx run-many -t buildacross all 8 workspace projects (no downstream breakage via the@effect-firebase/sourcesource-export condition).pnpm nx typecheck effect-firebase(the spec-project typecheck target) is red in the baseline before this change — 29 errors fromTS6307 "File is not listed within the file list of project"across source files imported by specs, plus unrelatedtransaction.spec.ts/reference.spec.ts/query.spec.tsissues. Stashing the fix and re-running on a clean tree produced the identical 29 errors with the identical breakdown, confirming this change adds no new errors; the authoritative type-level verification is the isolatedtscrun above.Automatic Fixes PRs can be configured here.