fix(core): honest return types for Result chaining and the batch envelope, plus a type-level test gate - #395
Merged
Conversation
…lope
Two typing defects, both invisible at runtime and both forcing callers to
work around a declaration that did not match the implementation.
`IResult.addError` / `addErrors` were declared as returning a bare `IResult`,
i.e. `IResult<any>`, so chaining threw the payload type away: after
`result.addError('x')`, `getData()` came back as `any` and a typo behind the
chain compiled. The class methods returned `Result<T>`, correct for `Result`
and wrong for every subclass — chaining off an `AjaxResult` widened it and lost
`getStatus()` / `getQuery()`. Both are now the polymorphic `this`.
`setData` was declared `(data: T)` while the implementation has always accepted
`T | null | undefined`, so clearing a result through an `IResult`-typed
reference was a type error against working code.
The batch transport passed `BatchPayload<T>` as the `AjaxResult` type argument.
`AjaxResult<X>` already means "the payload is `{ result: X, time }`", so this
described one envelope too many, and each processing strategy laundered the
difference with `as unknown as` — casts that looked cosmetic but were silencing
a real mismatch. The type argument is now `BatchResponsePayload<T>`, the union
of the two REST versions' actual shapes, and the remaining casts are plain
narrowings of that union.
Tests: type-level pins for both, in a new `jsSdk:types` vitest project. It is
the only project with `typecheck` enabled, and it has to be — `expectTypeOf` is
erased at runtime, and `test/` sits outside every `tsc` pass in the repo, so the
existing `call-params` pins were passing without checking anything. Both files
now run under that gate, wired into CI.
Non-breaking: no runtime behaviour changes, and every new declaration accepts
strictly more than the one it replaces.
Refs #279
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F22e2ft66y7nuBJjzdThBr
Docs the source change made stale:
- `70.core-result.md` / `90.types-iresult.md` still showed the old signatures
(`addError(): IResult`, `addErrors(): Result<T>`), so the pages now contradicted
the shipped types. Updated, with a line on what the chain buys.
- `90.types-payloads.md` presented `BatchPayload` as what a batch response
decodes to. It is the whole HTTP body, not the inner value — the distinction
that caused the bug in the first place — and the batch internals no longer use
it. Says so now, and points at `BatchResponsePayload`.
- Refreshed the `audited:` stamps on the pages citing `result.ts`.
`test/tsconfig.json` now extends the package config instead of restating a
subset of its flags. `strict` does not imply `noUnusedLocals`,
`noImplicitReturns` or `noPropertyAccessFromIndexSignature`, so a pin could have
passed the new gate while the real build rejected it — which defeats the point
of a project whose job is catching what the other passes miss. Verified by
mutation: an unused local now fails `jsSdk:types`.
Pinned `vitest` and `@vitest/ui` to exact versions. Vitest prints a warning that
its typecheck mode is experimental and may break outside semver; the new project
depends on that mode, so a caret range was carrying a risk the code comments
acknowledged and the manifest did not.
Tightened the pins in `call-params.types.spec.ts`. `toMatchTypeOf<Record<string,
unknown>>` and `not.toBeNever()` both pass against `any`, so they proved the
file compiled rather than what it compiled to — they now lead with
`not.toBeAny()` and assert assignability in the direction that fails if a union
arm is dropped.
Also `@returns {IResult}` -> `{IResult<T>}` in the JSDoc, and a note on
`BatchResponsePayload` that the union carries no discriminant: the v2/v3
coupling is held by the transport, not by the type system.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F22e2ft66y7nuBJjzdThBr
`"outDir": null` is not a legal compiler-option value. tsc happened to accept it here, but the intent — clear the parent's `outDir` — is not something it expresses. Removing it exposed what it had been papering over: the parent emits declarations, so tsc infers a `rootDir` of `test/` and every import reaching into `packages/jssdk/src` becomes "not under rootDir". Set `rootDir` to the repository root instead. Nothing is written either way; `noEmit` is on. Verified with a direct `tsc -p test/tsconfig.json`, which the vitest runner had not been surfacing. Both type-spec headers still said "Portal-free (jsSdk:unit)" — the wrong project, and pointing a reader at exactly the vacuous-run trap this PR exists to close. They run under `jsSdk:types`, which is what makes their assertions capable of failing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F22e2ft66y7nuBJjzdThBr
The type gate inherits the package build config so its pins are checked under exactly the flags the build uses. That coupling had a signal in only one direction: whoever edits the package config for build reasons has no reason to look in `test/`, and a comment there does not reach them. Says so at the top of the config they would actually be editing. Filed #396 for the proper fix — invert the inheritance into a shared base config — since that touches the build config and wants its own PR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F22e2ft66y7nuBJjzdThBr
`"exclude": []` does not mean "nothing to exclude" — it replaces TypeScript's implicit defaults, so `node_modules` stops being excluded. Inert against the current include glob, which is why it passed, but it is a footgun waiting for the first generated or vendored path that matches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F22e2ft66y7nuBJjzdThBr
8 tasks
IgorShevchik
pushed a commit
that referenced
this pull request
Aug 26, 2026
`TypeCallParamsV3.filter` accepts both the v3 array of triples and the v2 object
dialect, kept for backward compatibility. That union is right for a single
`call`, which forwards the filter untouched. It is wrong for `callList` and
`fetchList`, which emulate keyset pagination by appending
`[cursorIdKey, '>', cursor]` to the filter on every page — an array is not a
preference there, it is the only shape the mechanism can extend.
So `b24.actions.v3.callList.make({ filter: { '>id': 100 } })` — a shape the
public type allows and the documentation promises — threw `filter is not
iterable` from a spread, one page into the walk. Confirmed rather than reasoned
about: spreading a plain object throws `is not iterable` at runtime.
The two list actions now narrow `filter` to `TypeFilterV3` in their own options
type, so the mistake is a compile error rather than a crash, and a runtime guard
catches the JavaScript caller's version with a code and a message that names the
fix. This is not a behaviour change anyone can be relying on: the path it closes
could only ever throw.
Also names the v2 batch request envelope. `TypeHttp.call` types its params as
`TypeCallParams`, and the batch request rides through it — `{ halt, cmd }` is
neither a filter nor a select, and it type-checks only because of the permissive
index signature. `BatchRequestEnvelopeV2` gives that shape a name at the site
that builds it. On the v3 side there is no envelope at all — the commands are
the request body — so the cast there stays, with a comment saying why rather
than looking like an oversight. Same class as the response-envelope mistyping
fixed in #395, the other direction.
Found while measuring the `TypeCallParams` options for #279.
Refs #279
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F22e2ft66y7nuBJjzdThBr
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two typing defects in
2.x, both invisible at runtime, both making callers work around a declaration that did not match the implementation — plus the test gate that makes type-level assertions in this repo mean anything.Split out of #279: this is the half that needs no design decision.
What "non-breaking" means here, precisely
No runtime behaviour changes at all. The emitted
.d.tsdoes change:Result.setData/addError/addErrorsnow returnthisrather thanResult<T>, andIResult.addError/addErrorsreturnIResult<T>rather than a bareIResult. Every new declaration accepts strictly more and returns strictly more precisely than the one it replaces, so a caller that chains or ignores the return value is unaffected. Someone doing an exact signature comparison against the old interface — asatisfiescheck, or their own class declaringaddError(...): IResult— still type-checks, becauseIResultisIResult<any>and assignable in both directions. Calling it non-breaking is a judgement about a type-only change, not a claim that nothing moved; flagging it explicitly rather than leaving it implicit.1.
Resultchaining threw the type awayIResult.addError/addErrorswere declared as returning a bareIResult— that is,IResult<any>:The class methods returned
Result<T>: right forResult, wrong for every subclass — chaining off anAjaxResultwidened it back and lostgetStatus()/getQuery(). Both are now the polymorphicthis.setDatawas declared(data: T) => …while the implementation has always acceptedT | null | undefined, so clearing a result through anIResult-typed reference was a type error against code that works. (The docs page had it right and the code did not.)2. The batch envelope was described one level too deep
AjaxResult<X>already means "the body is{ result: X, time }". The batch transport passedBatchPayload<T>— itself{ result, time }— so the declared shape was{ result: { result: …, time }, time }.Nothing failed, because every processing strategy laundered the difference:
Those
as unknown ascasts read as cosmetic and were load-bearing: they silenced a real mismatch. Worse, the old type madegetData()!.resultresolve to the v2 envelope even under the v3 transport, whoseresultis a bare array/record.The type argument is now
BatchResponsePayload<T>— the union of what the two versions actually send — and the remaining casts are plain narrowings of one of its arms. Documented alongside it: the union carries no discriminant, so the v2/v3 pairing is held by the transport, not by the type system.3. A gate that was passing vacuously
New
jsSdk:typesvitest project — the only one withtypecheckenabled, and it has to be.expectTypeOfis erased at runtime, so under a plainvitest runa type assertion cannot fail, andtest/sits outside everytscpass in the repo. The existingcall-params-types.unit.spec.tswas reporting as passing while checking nothing; it is renamed tocall-params.types.spec.tsand now runs under the gate, with its weakest pins tightened (toMatchTypeOf<Record<string, unknown>>andnot.toBeNever()both pass againstany, so they proved the file compiled, not what it compiled to).test/tsconfig.jsonextends the package config rather than restating a subset of its flags —strictdoes not implynoUnusedLocals,noImplicitReturnsornoPropertyAccessFromIndexSignature, and a hand-copied set drifts silently. That inheritance runs in the unusual direction (a root-level test dir depending on a package build config), sopackages/jssdk/tsconfig.jsonnow names its second consumer at the top, and #396 tracks the proper fix — inverting it into a shared base config, which touches the build config and wants its own PR.vitestand@vitest/uiare pinned to exact versions, because Vitest warns that this typecheck mode is experimental and may break outside semver. Dependabot covers the root manifest and rewrites exact specifiers just as it does ranges, so the pin does not become a blind spot for a security patch.Everything verified by mutation, not assumption: reverting
addErrortoResult<T>fails the spec; dropping either arm of the v3 filter union fails the tightened pins; an unused local fails, proving the stricter inherited flags are live.Checks
pnpm run typecheck— all eight passes greenjsSdk:unit+skills:unit+jsSdk:types— 58 files, 650 tests, no type errorslint(1 pre-existing unrelated warning indocs/server/api/ai.post.ts, deliberately untouched),lint:md,md-internal-links,docs-lint --strict,check-api-reference-index,check-v3-method-refstsc -p test/tsconfig.json --noEmitrun directly — the vitest runner had been hiding a config error that only a direct invocation surfacedbatch-null-result/batch-array-error-key/http-batch-soft-errorspecs, which decode real envelopes throughgetData()and still pass — the expression read out of it is byte-identical before and after.Lockfile
Regenerating the lock for the pin also moved a transitive
tinyexecfrom1.2.4to1.3.0in five blocks. That is the whole of the rest of the lockfile diff — no added or removed packages, no changed integrity hashes elsewhere — and it is the resolution vitest4.1.10itself asks for. Naming it so the line is not mistaken later for an unexplained lockfile edit.Docs
70.core-result.md,90.types-iresult.mdand90.types-payloads.mdwere left stale by the source change and are updated — the payloads page in particular presentedBatchPayloadas what a batch response decodes to, which is the exact conflation that caused the bug..github/contributing/testing.mdgains a section on the*.types.spec.tssuffix, and loses a line that had become false: it claimed CI does not run Vitest, while thetestjob already runs the portal-free projects.🤖 Generated with Claude Code
https://claude.ai/code/session_01F22e2ft66y7nuBJjzdThBr