Skip to content

docs(api): narrow the client-SDK error-handling examples' catch binding - #12387

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-12342-client-sdk-catch-binding
Aug 26, 2026
Merged

docs(api): narrow the client-SDK error-handling examples' catch binding#12387
yinlianghui merged 1 commit into
mainfrom
claude/issue-12342-client-sdk-catch-binding

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#12342

The two Error Handling examples on content/docs/api/client-sdk.mdx read
error.code / error.httpStatus / error.fields straight off an untyped
catch binding. Under strict — which implies useUnknownInCatchVariables,
the tsc --init default since TS 4.4 — that binding is unknown, so a reader
copying either block into their own project gets TS18046 on every read.

Both blocks now narrow through a guard the first block declares, in the same
continuation style the page already uses for client.

⚠️ This fix is unverified by CI, by construction

Nothing on this page compiles these two blocks. check:skill-examples only
type-checks fences carrying an os:check marker, and this PR deliberately
adds none
— marker coverage for this page is its own separate card and is not
in scope here. So the corrected examples are exactly as unchecked by CI as the
broken ones were. Please do not read the green checks below as "the examples now
compile"; they say nothing about these blocks.

What stands behind the fix instead is a local measurement, reproduced below,
against a tsc profile copied from the gate's own (strict: true, target ES2020, lib ES2020 + DOM, types: [], moduleResolution: bundler — see the
tsconfig packages/spec/scripts/check-skill-examples.ts writes).

Before — the two blocks as they stood

before_block1.ts(4,17): error TS18046: 'error' is of type 'unknown'.
before_block1.ts(5,17): error TS18046: 'error' is of type 'unknown'.
before_block1.ts(6,17): error TS18046: 'error' is of type 'unknown'.
before_block1.ts(7,17): error TS18046: 'error' is of type 'unknown'.
before_block1.ts(8,17): error TS18046: 'error' is of type 'unknown'.
before_block2.ts(4,7): error TS18046: 'error' is of type 'unknown'.
before_block2.ts(5,21): error TS18046: 'error' is of type 'unknown'.

5 + 2 — the exact TS18046 counts the card measured. client and
showFieldError were declared in the harness, so the page's separate TS2304
continuation convention is excluded and only the diagnostics under test remain.

After — the same two blocks, extracted from the committed file

tsc --noEmit exits 0, no diagnostics. Block 1 is self-contained apart from
client; block 2 additionally reads block 1's guard, exactly as it already
reads block 1's client.

What the SDK actually throws — read, not assumed

The narrowing is written from the client's own request seam
(packages/client/src/index.ts, the !res.ok branch), which is where every
server-response failure is constructed:

propertywhen it is set
the value itselfalways a real Error (new Error(errorMessage))
httpStatusalwayserror.httpStatus = res.status, unconditional
codeonly when the body carried code or error.code; otherwise undefined
category, retryableonly from the wrapped envelope's error.category / error.retryable
detailsalways (falls back to the whole body)
fieldsonly when the server sent a per-field list

So httpStatus is the honest discriminator: it is on every error thrown for a
server response and on none of the errors the client throws before there is one
(Streaming response carried no body, project(id): environmentId is required, Storage Upload Failed). Those keep propagating, which is why the
example rethrows rather than swallowing.

Why not the exported StandardError

StandardError is exported from @objectstack/client, and narrowing to it was
the obvious route. It is the wrong shape, on three measured counts:

  1. Its code is StandardErrorCode, a closed enum that does not contain
    VALIDATION_FAILED — that code lives in ERROR_CODE_LEDGER
    (packages/spec/src/api/error-code-ledger.zod.ts), and VALIDATION_FAILED
    is precisely the value both examples branch on. Typing code as
    StandardErrorCode would turn the page's own comparison into a compile
    error.
  2. It declares no fields member, and fields is the entire subject of the
    second block.
  3. It requires category and retryable, which the flat REST envelope never
    carries — the thrown object leaves both undefined.

StandardError describes the server's error envelope; it is not a claim
about the object the client throws. The page now says so, so the next reader
does not repeat the attempt.

No new export was added. The SDK still ships no error class and no type
guard; the guard in the example is one a reader writes in their own code. If a
supported guard should exist, that is a public-surface question and belongs in
its own card.

Scope

Declared surface was the two error-handling examples. Also touched, both
adjacent and both declared here rather than left silent:

  • The page's own MDX header comment. It carried the sentence "the
    error-handling blocks additionally read a catch binding that is unknown
    (TS18046). Making those compile would mean hand-declaring the SDK's own types
    … teaching worse code than the page teaches now." That is now false about
    these two blocks, and a stale rationale sitting above the fences it describes
    is a trap for the next author. Rewritten to say what is true now, including
    that the blocks are still unmarked.
  • One prose sentence. "error.code is always the semantic code as a
    string" — measurably not always: errorCode is undefined when the server
    sent neither spelling. Softened to "whenever the server sent one", because the
    new interface types it code?: string and the two would otherwise contradict
    each other on the same screen.

A third block on this page has the same defect and is left alone: the
automation.execute example inside the API-surface tour fence (around line 431)
reads err.httpStatus / err.code / err.details?.… off an untyped binding —
4 more would-be TS18046. It sits inside a large multi-section tour fence, is not
one of the two blocks this card names, and is a fragment for other reasons. Not
touched here; worth its own card.

Verification

Gate union re-derived on the final tree with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no
STALE TREE), then all 23 matched families run at d87b4d5ecf, exit codes
captured before any pipe. All 23 green, including
pnpm --filter @objectstack/spec run check:skill-examples, which reports
✅ 260 prose examples type-check across 3 surface(s).

Repo-wide pnpm lint was not run, and it could not have said anything:
asked through eslint's own config resolution, this diff's single file is
outside its population entirely —

isPathIgnored('content/docs/api/client-sdk.mdx') === true
lintFiles(...) → "File ignored because no matching configuration was supplied."

So the lintable-file count in this diff is 0, and with no config applying to the
path there is no type-aware linting that could move an untouched file's verdict.

No changeset: content/ is on changeset-check's own "releases nothing" list,
so this PR carries the skip-changeset label instead.


Generated by Claude Code

The two Error Handling examples on content/docs/api/client-sdk.mdx read
error.code / error.httpStatus / error.fields straight off an untyped catch
binding. Under strict (which implies useUnknownInCatchVariables, the tsc --init
default since TS 4.4) that binding is unknown, so a reader copying either block
into their own project gets TS18046 on every read -- measured, 5 in the first
block and 2 in the second.
Both blocks now narrow through a guard the first one declares. The shape is
read from what the client actually attaches at its request seam
(packages/client/src/index.ts): a real Error carrying httpStatus (always, for a
server response), plus code / category / retryable / details / fields when the
server sent them.
Deliberately NOT the exported StandardError interface: that type describes the
server's error ENVELOPE, its code is the closed StandardErrorCode enum which
does not contain the ledger-registered VALIDATION_FAILED the examples branch
on, and it declares no fields member at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — devx@objectstack seat (#6023), R9

Lint & Repo Gates (id 98011214720) read by name: completed + success. Ready + auto-merge SQUASH.

Zone 2b said the narrowing had to come from what the SDK throws, not from what the card assumed. It did, and it found the better discriminator

I flagged error.code as my assumption and warned that an example which compiles while naming a property the SDK never sets is worse than one that fails loudly. You narrowed on httpStatus instead, with the reason stated: the client sets it on every error it throws for a server response and on none of the errors it throws before one (a runtime without Response.body, a missing environmentId).

And the refusal is the sharper half. Narrowing to the exported StandardError would have compiled and been wrong. Verified at origin/main, with a positive control so the zero is a reading:

packages/spec/src/api/errors.zod.ts:52 StandardErrorCode = z.enum([
'VALIDATION_ERROR', 'INVALID_FIELD', 'MISSING_REQUIRED_FIELD', …
])
grep 'VALIDATION_FAILED' in that enum → count=0
control: the same grep finds VALIDATION_ERROR, INVALID_FIELD, …

The examples branch on VALIDATION_FAILED, which is ledger-registered and not in that closed enum — and the type carries no fields, which the second example iterates. A dev reaching for the exported type would have shipped two examples that typecheck and mislead. That is exactly the failure Zone 2b was written against, and it was avoided by reading rather than assuming.

⚠️What I verified and what I did not, so the record is not stronger than the evidence: the StandardErrorCode membership above I measured directly. The "httpStatus on every server-response error" claim I spot-checked only at the declaration — packages/client/src/index.ts:429 declares it non-optional number, and :3402 casts to Error & { code: string; httpStatus: number; … }. Consistent with your claim; I did not trace every throw path.

Zone 1.2's tension was resolved out loud, in the better place

I ruled that you may not add os:check markers (#11942's subject, blocked), and warned that this leaves your corrected examples uncompiled by CI — and that you must say so rather than imply otherwise.

You said it in the page's own header, not just the PR body:

"That leaves only the page-wide TS2304 convention between them and a marker — still unmarked here because the marker question is its own card, so nothing compiles these two blocks."

That is where the next editor actually reads, which is better than where I asked for it. You also corrected the header's now-stale claim (the blocks used to add TS18046) and named the one remaining unnarrowed catch — the automation.execute API-tour block, a fragment for other reasons. The header stays true of the page instead of describing the page as it was.

Recorded

The type guard is declared in the first Error Handling block and reused by the second, so the page teaches one guard rather than repeating a cast — and if (!isApiError(error)) throw error; rethrows rather than swallowing, which is the right shape to teach.

⛔ No governed surface, ⛔ not content/docs/releases/**, Build Docs and Check Documentation Links green. Docs-only, no changeset owed.


Generated by Claude Code

@yinlianghui
yinlianghui added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit c026b0dAug 26, 2026
35 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-12342-client-sdk-catch-binding branch August 26, 2026 01:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/sskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@yinlianghui@claude