Skip to content

[sdk-client S1] fix(sdk): preserve contract errors through safe() and isDefinedError #1350

Description

@rickylabs

Summary

NetScript re-implements oRPC's safe() and isDefinedError() without the TError generic, so the
error channel is unknown, Extract<unknown, DefinedError> evaluates to never, and reading
error.code after a successful isDefinedError narrow is a type error. This is proven by an
executed deno check, and it is the exact snippet the published docs tell users to write
(docs/site/services-sdk/sdk.md:199). A contributing cause sits one layer down: baseContract is
annotated ReturnType<typeof oc.errors>, which instantiates the generic at its constraint and erases
the six declared error codes to the open ErrorMap index signature — so even a correct safe()
could not narrow to NetScript's error vocabulary today. oRPC's headline typed-error DX is fully lost
while three published documents assert it works.

Evidence

  • Corpus: research/repo-audit/services-sdk.md §3.4, §3.5, gap register S1/S2/S3;
    research/external/orpc.md §4 (G4) with the executed probe transcript.

  • Source at baseline fac9e339042c (re-verified for this draft):

    • packages/sdk/src/client/errors.ts:75-77
      export function isDefinedError<T>(error: T): error is Extract<T, DefinedError>.
    • packages/sdk/src/client/errors.ts:86-92
      export async function safe<TOutput>(promise: PromiseLike<TOutput>): Promise<SafeResult<TOutput>>.
    • packages/sdk/src/client/errors.ts:49SafeResult<TOutput, TError = unknown>, so TError
      defaults to unknown at every call site.
    • packages/sdk/src/ports/service-client.ts:160-171ServiceClientMethod returns
      Promise<TOutput>, discarding upstream's ClientPromiseResult<TOutput, ErrorFromErrorMap<…>>.
    • packages/contracts/src/application/contract-primitives.ts:81
      export const baseContract: ReturnType<typeof oc.errors> = oc.errors(commonErrorMap); (the six
      codes are declared at :21-52); the doc comment at :54-69 claims the contract is "genuinely
      typed rather than erased to any" — true for input/output, false for the error map.
    • docs/site/services-sdk/sdk.md:199 — tab "Safe error narrowing" ships
      if (isDefinedError(error)) return { code: error.code, status: error.status };.
    • docs/site/services-sdk/how-to/discover-services.md:138-147 and :212-224 — the same pattern,
      with the comment "error.code and error.data are typed from the contract".
    • packages/sdk/tests/readme-doctest_test.ts:36-37 — the guard that should have caught this
      re-declares safe and isDefinedError with different signatures instead of importing them,
      so the doctest passes against a fiction.
  • Executed probe (reproduced from research/repo-audit/services-sdk.md §8; re-run before filing):

    import { isDefinedError, safe } from 'packages/sdk/src/client/mod.ts';
    declare const p: Promise<{ ok: boolean }>;
    const [error] = await safe(p);
    if (error && isDefinedError(error)) { const c: string = error.code; }

    deno check --unstable-kv --config deno.json <probe>.ts
    TS2339 [ERROR]: Property 'code' does not exist on type 'never'.

  • Upstream signature (pinned 1.14.6): safe<TOutput, TError = ThrowableError>(promise: ClientPromiseResult<TOutput, TError>): Promise<SafeResult<TOutput, TError>>.

Current surface

Three published documents and one README export table advertise contract-typed error narrowing;
the shipped helpers cannot provide it, and the only test that touches them tests re-declared
fictions. Consumers who follow the docs get a compile error, and the workaround the compiler pushes
them toward is error as { code: string } — an unsound cast the type-soundness epic (#1278) then
counts as debt.

Target contract

Per RFC-A §3.7:

  1. safe<TOutput, TError = ThrowableError> and isDefinedError<T> regain the upstream shape, with
    SafeResult<TOutput, TError>'s failure arms discriminating on isDefined exactly as upstream
    does.
  2. ServiceClientMethod carries the error channel so TError reaches safe() from the contract —
    either by aliasing to oRPC's derived client type or by threading
    ErrorFromErrorMap<TErrorMap> through the existing structural derivation.
  3. baseContract's annotation preserves the six literal error-map keys. ReturnType<typeof oc.errors> is replaced with a spelling that is both literal-preserving and
    --isolatedDeclarations-safe, following the precedent already set by BaseContractRoute /
    BaseContractOutputRoute (packages/contracts/src/application/contract-primitives.ts:125-159).
  4. packages/sdk/tests/readme-doctest_test.ts imports the real helpers; the local declare function shims are deleted.

Acceptance

  • safe and isDefinedError carry the upstream error generic and SafeResult discriminates on
    isDefined.
  • baseContract preserves the six literal error-map keys and the RFC 0001 procedure-metadata
    type through contract construction, client inference, and handler inference.
  • A type fixture proves error.code narrows to the declared union after isDefinedError and
    procedure metadata remains available without widening to unknown.
  • NEGATIVE: undeclared codes are rejected; non-oRPC thrown values do not narrow to defined errors;
    dropping metadata at any contract/client/handler boundary makes the fixture red.
  • The SDK reference and discovery snippets compile as written, and the README doctest imports the
    real package helpers rather than redeclaring them.
  • deno task publish:dry-run passes for SDK/contracts with isolated declarations; root check and
    test pass.

Boundaries

Docs/consumer proof

The proof is executable: the two published snippets compile unmodified, and the README doctest
exercises the real exports. Consumer-side, a scaffolded service-to-service call can branch on a
contract error without a cast — demonstrate it in the fixture so the pattern is greppable, and state
in the SDK reference that error.data is typed from the contract's Zod schema.

Provenance

Seed run plan-fable5-remediation-roadmap--seed, PR #1347, 2026-08-08. Sourced from
research/repo-audit/services-sdk.md (S1, S2, S3) and research/external/orpc.md (G4), both of
which carry executed deno check transcripts; all cited lines re-verified against worktree baseline
fac9e339042c. No GitHub mutation was performed.


Filed from planning seed PR #1347 · source Draft-ID T1-03 · live issue #1350.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions