Skip to content

fix(core): honest return types for Result chaining and the batch envelope, plus a type-level test gate - #395

Merged
IgorShevchik merged 5 commits into
mainfrom
claude/text-tools-docs-refactor-mb9dj7
Aug 26, 2026
Merged

fix(core): honest return types for Result chaining and the batch envelope, plus a type-level test gate#395
IgorShevchik merged 5 commits into
mainfrom
claude/text-tools-docs-refactor-mb9dj7

Conversation

@IgorShevchik

@IgorShevchik IgorShevchik commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

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.ts does change: Result.setData / addError / addErrors now return this rather than Result<T>, and IResult.addError / addErrors return IResult<T> rather than a bare IResult. 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 — a satisfies check, or their own class declaring addError(...): IResult — still type-checks, because IResult is IResult<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. Result chaining threw the type away

IResult.addError / addErrors were declared as returning a bare IResult — that is, IResult<any>:

const data = Result.ok<Deal>({ id: 1 }).addError('boom').getData()
//    ^? any  — the payload type is gone, and a typo behind the chain compiles

The class methods returned Result<T>: right for Result, wrong for every subclass — chaining off an AjaxResult widened it back 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 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 passed BatchPayload<T> — itself { result, time } — so the declared shape was { result: { result: …, time }, time }.

Nothing failed, because every processing strategy laundered the difference:

const responseResult = responseHelper.response.getData()!.result as unknown as BatchResponseData<T>

Those as unknown as casts read as cosmetic and were load-bearing: they silenced a real mismatch. Worse, the old type made getData()!.result resolve to the v2 envelope even under the v3 transport, whose result is 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:types vitest project — the only one with typecheck enabled, and it has to be. expectTypeOf is erased at runtime, so under a plain vitest run a type assertion cannot fail, and test/ sits outside every tsc pass in the repo. The existing call-params-types.unit.spec.ts was reporting as passing while checking nothing; it is renamed to call-params.types.spec.ts and now runs under the gate, with its weakest pins tightened (toMatchTypeOf<Record<string, unknown>> and not.toBeNever() both pass against any, so they proved the file compiled, not what it compiled to).

test/tsconfig.json extends the package config rather than restating a subset of its flags — strict does not imply noUnusedLocals, noImplicitReturns or noPropertyAccessFromIndexSignature, 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), so packages/jssdk/tsconfig.json now 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.

vitest and @vitest/ui are 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 addError to Result<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 green
  • jsSdk:unit + skills:unit + jsSdk:types — 58 files, 650 tests, no type errors
  • lint (1 pre-existing unrelated warning in docs/server/api/ai.post.ts, deliberately untouched), lint:md, md-internal-links, docs-lint --strict, check-api-reference-index, check-v3-method-refs
  • tsc -p test/tsconfig.json --noEmit run directly — the vitest runner had been hiding a config error that only a direct invocation surfaced
  • Portal-backed projects not run: no transport behaviour changed. The runtime side of the batch retyping is covered by the existing batch-null-result / batch-array-error-key / http-batch-soft-error specs, which decode real envelopes through getData() 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 tinyexec from 1.2.4 to 1.3.0 in 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 vitest 4.1.10 itself asks for. Naming it so the line is not mistaken later for an unexplained lockfile edit.

Docs

70.core-result.md, 90.types-iresult.md and 90.types-payloads.md were left stale by the source change and are updated — the payloads page in particular presented BatchPayload as what a batch response decodes to, which is the exact conflation that caused the bug. .github/contributing/testing.md gains a section on the *.types.spec.ts suffix, and loses a line that had become false: it claimed CI does not run Vitest, while the test job already runs the portal-free projects.

🤖 Generated with Claude Code

https://claude.ai/code/session_01F22e2ft66y7nuBJjzdThBr

claude added 2 commits August 26, 2026 04:36
…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
@IgorShevchik IgorShevchik changed the title fix(core): honest return types for Result chaining and the batch envelope fix(core): honest return types for Result chaining and the batch envelope, plus a type-level test gate Aug 26, 2026
`"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
claude added 2 commits August 26, 2026 04:48
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
@IgorShevchik
IgorShevchik merged commit c7783c3 into main Aug 26, 2026
10 checks passed
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants