Skip to content

Commit b18ea2c

Browse files
feat(typed)(#113): kind discriminator on safe error envelope (#115)
* feat(typed)(#113): kind discriminator on safe error envelope Replace TypedSafeErr<R> with a discriminated union on `kind`: - `kind: "http"` — server responded with a non-2xx status declared in `responses`. `error.status` narrows to the `ErrorCodes` union and `error.data` narrows to the body shape for that status. `response` is a real `Response`. - `kind: "network"` — no server response (TCP/TLS failure, DNS, timeout, abort). `error` is the raw `Error`; `response` is `undefined`. Why: the previous shape coerced network failures into a synthetic `{ status: 0, data: error.message }` envelope. When `ErrorCodes` was declared as `404 | 429`, TypeScript narrowed `error.status` to that union but at runtime `status` could be 0 — the type lied. The new shape exposes the raw `Error` so callers branch on `kind === "network"` before touching `status`. Migration: callers now check `result.kind` before reading `error.status`: if (!result.ok) { if (result.kind === "network") result.error // Error else if (result.error.status === 429) ... // narrows } The success branch (`ok: true`) is unchanged — issue #113's proposal keeps `kind` only on the error side. Closes #113 * chore(review): drop defensive Error coercion in safe wrapper Drivers and misina's internal request path always throw `Error` subclasses per AGENTS.md ("drivers throw TypeError('fetch failed')" to trip the NetworkError mapper). The `e instanceof Error ? e : new Error(...)` shim was defensive code against a contract violation that internal callers cannot produce — `e as Error` matches the same runtime behavior without the dead branch.
1 parent be68296 commit b18ea2c

6 files changed

Lines changed: 158 additions & 21 deletions

File tree

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1876,12 +1876,27 @@ const result = await api.safe.get("/users/:id", { params: { id: "42" } })
18761876
if (result.ok) {
18771877
result.data // User
18781878
result.status // 200
1879+
} else if (result.kind === "network") {
1880+
result.error // Error — fetch failed, timeout, abort
18791881
} else {
1882+
// result.kind === "http"
18801883
if (result.error.status === 404) result.error.data.message // string
18811884
if (result.error.status === 429) result.error.data.retryAfter // number
18821885
}
18831886
```
18841887

1888+
The error envelope is a discriminated union on `kind`:
1889+
1890+
- `kind: "http"` — the server responded with a non-2xx status declared in
1891+
`responses`. `error.status` narrows to the `ErrorCodes` union and
1892+
`error.data` narrows to the body shape for that status. `response` is a
1893+
real `Response`.
1894+
- `kind: "network"` — the request never reached a server (TCP/TLS failure,
1895+
DNS, timeout, abort). `error` is the raw `Error` instance and
1896+
`response` is `undefined`. There is no HTTP status to discriminate on,
1897+
so the envelope is kept honest by exposing `Error` directly instead of
1898+
forging a synthetic `status: 0`.
1899+
18851900
The throwing surface (`api.get(...)`) is unchanged: it returns the
18861901
2xx body as before. `response: T` remains valid as shorthand for
18871902
`responses: { 200: T }`.

examples/07-typed.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,10 @@ const result = await gh.safe.get("/repos/:owner/:repo", {
5757

5858
if (result.ok) {
5959
console.log(`fetched ${result.data.full_name}`)
60+
} else if (result.kind === "network") {
61+
console.log(`network error: ${result.error.message}`)
6062
} else {
63+
// result.kind === "http" — error.status narrows to the ErrorCodes union.
6164
if (result.error.status === 404) console.log(`not found: ${result.error.data.message}`)
6265
if (result.error.status === 403) console.log(`forbidden: ${result.error.data.message}`)
6366
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,9 @@ export type {
9393
SuccessCodes,
9494
TypedMisina,
9595
TypedSafeErr,
96+
TypedSafeHttpErr,
9697
TypedSafeMisina,
98+
TypedSafeNetworkErr,
9799
TypedSafeOk,
98100
TypedSafeResult,
99101
} from "./typed.ts"

src/typed.ts

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,15 +64,44 @@ export type TypedSafeOk<R> = {
6464
error?: undefined
6565
}
6666

67-
export type TypedSafeErr<R> = {
67+
/**
68+
* Per-status HTTP error branch. The server responded with a status the
69+
* endpoint declared as an error — `error.status` narrows to the union of
70+
* declared `ErrorCodes`, and `error.data` narrows to the body shape for
71+
* that status.
72+
*/
73+
export type TypedSafeHttpErr<R> = {
6874
ok: false
75+
kind: "http"
6976
data?: undefined
7077
error: [keyof R & ErrorCodes] extends [never]
7178
? { status: number; data: unknown }
7279
: { [K in keyof R & ErrorCodes]: { status: K; data: R[K] } }[keyof R & ErrorCodes]
73-
response: Response | undefined
80+
response: Response
81+
}
82+
83+
/**
84+
* Network / timeout / abort branch. The request never received a server
85+
* response — there is no HTTP status to discriminate on. `error` is the
86+
* raw thrown `Error` (TypeError, TimeoutError, etc.); `response` is
87+
* `undefined`.
88+
*/
89+
export type TypedSafeNetworkErr = {
90+
ok: false
91+
kind: "network"
92+
data?: undefined
93+
error: Error
94+
response: undefined
7495
}
7596

97+
/**
98+
* Discriminated union covering both error branches. Use `result.kind` to
99+
* separate the wire-level HTTP error (where `result.error.status` is a
100+
* declared `ErrorCodes`) from the transport-level failure (where
101+
* `result.error` is a raw `Error`).
102+
*/
103+
export type TypedSafeErr<R> = TypedSafeHttpErr<R> | TypedSafeNetworkErr
104+
76105
export type TypedSafeResult<R> = TypedSafeOk<R> | TypedSafeErr<R>
77106

78107
type Method<S extends string> = S extends `${infer M} ${string}` ? M : never
@@ -182,9 +211,17 @@ export function createMisinaTyped<E extends EndpointsMap>(
182211
| { ok: true; data: unknown; status: number; response: Response; error?: undefined }
183212
| {
184213
ok: false
214+
kind: "http"
185215
data?: undefined
186216
error: { status: number; data: unknown }
187-
response: Response | undefined
217+
response: Response
218+
}
219+
| {
220+
ok: false
221+
kind: "network"
222+
data?: undefined
223+
error: Error
224+
response: undefined
188225
}
189226

190227
async function safeCall(
@@ -199,17 +236,22 @@ export function createMisinaTyped<E extends EndpointsMap>(
199236
if (e instanceof HTTPError) {
200237
return {
201238
ok: false,
239+
kind: "http",
202240
error: { status: e.status, data: e.data },
203241
response: e.response,
204242
}
205243
}
206-
// Network / timeout / other non-HTTP errors don't fit the per-status
207-
// shape — surface a synthetic error envelope so callers can still
208-
// discriminate on `ok`. Rethrowing would defeat .safe's purpose.
209-
const err = e as Error & { status?: number; data?: unknown }
244+
// Network / timeout / abort / anything non-HTTP. There is no server
245+
// response to discriminate on, so surface the raw `Error` and let
246+
// the caller branch on `kind === "network"`. Rethrowing would
247+
// defeat .safe's purpose; coercing into a fake `status: 0` would
248+
// lie about the typed `ErrorCodes` union. Drivers and internal code
249+
// throw `Error` subclasses per AGENTS.md ("drivers throw
250+
// TypeError('fetch failed')"), so no defensive coercion here.
210251
return {
211252
ok: false,
212-
error: { status: err.status ?? 0, data: err.data ?? err.message },
253+
kind: "network",
254+
error: e as Error,
213255
response: undefined,
214256
}
215257
}

test/typed-safe.test.ts

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { describe, expect, expectTypeOf, it } from "vitest"
22
import { createMisinaTyped } from "../src/index.ts"
3-
import type { ResponsesOf, SuccessBodyOf, TypedSafeResult } from "../src/typed.ts"
3+
import type {
4+
ResponsesOf,
5+
SuccessBodyOf,
6+
TypedSafeErr,
7+
TypedSafeHttpErr,
8+
TypedSafeNetworkErr,
9+
TypedSafeResult,
10+
} from "../src/typed.ts"
411
import mockDriverFactory from "../src/driver/mock.ts"
512

613
interface User {
@@ -71,7 +78,7 @@ describe("createMisinaTyped — .safe namespace", () => {
7178
}
7279
})
7380

74-
it("safe.get returns ok=false with error.status === 404 and typed data", async () => {
81+
it("safe.get returns ok=false, kind=http with error.status === 404 and typed data", async () => {
7582
const driver = mockDriverFactory({
7683
response: jsonResponse({ message: "not found" }, 404),
7784
})
@@ -80,8 +87,9 @@ describe("createMisinaTyped — .safe namespace", () => {
8087
const result = await api.safe.get("/users/:id", { params: { id: "x" } })
8188

8289
expect(result.ok).toBe(false)
83-
if (!result.ok) {
90+
if (!result.ok && result.kind === "http") {
8491
expect([404, 429]).toContain(result.error.status)
92+
expect(result.response).toBeInstanceOf(Response)
8593
// Discriminated narrowing on status:
8694
if (result.error.status === 404) {
8795
expectTypeOf(result.error.data).toEqualTypeOf<NotFound>()
@@ -90,7 +98,7 @@ describe("createMisinaTyped — .safe namespace", () => {
9098
}
9199
})
92100

93-
it("safe.get carries 429 body shape on rate-limit", async () => {
101+
it("safe.get carries 429 body shape on rate-limit (kind=http)", async () => {
94102
const driver = mockDriverFactory({
95103
response: jsonResponse({ retryAfter: 30 }, 429),
96104
})
@@ -99,7 +107,7 @@ describe("createMisinaTyped — .safe namespace", () => {
99107
const result = await api.safe.get("/users/:id", { params: { id: "x" } })
100108

101109
expect(result.ok).toBe(false)
102-
if (!result.ok && result.error.status === 429) {
110+
if (!result.ok && result.kind === "http" && result.error.status === 429) {
103111
expectTypeOf(result.error.data).toEqualTypeOf<RateLimited>()
104112
expect(result.error.data.retryAfter).toBe(30)
105113
}
@@ -120,7 +128,7 @@ describe("createMisinaTyped — .safe namespace", () => {
120128
}
121129
})
122130

123-
it("safe.post returns ok=false with 422 validation error", async () => {
131+
it("safe.post returns ok=false, kind=http with 422 validation error", async () => {
124132
const driver = mockDriverFactory({
125133
response: jsonResponse({ issues: ["name too short"] }, 422),
126134
})
@@ -129,7 +137,7 @@ describe("createMisinaTyped — .safe namespace", () => {
129137
const result = await api.safe.post("/users", { body: { name: "" } })
130138

131139
expect(result.ok).toBe(false)
132-
if (!result.ok && result.error.status === 422) {
140+
if (!result.ok && result.kind === "http" && result.error.status === 422) {
133141
expectTypeOf(result.error.data).toEqualTypeOf<{ issues: string[] }>()
134142
expect(result.error.data.issues).toEqual(["name too short"])
135143
}
@@ -147,7 +155,7 @@ describe("createMisinaTyped — .safe namespace", () => {
147155
}
148156
})
149157

150-
it("safe.get surfaces network errors as ok=false with status 0 instead of throwing", async () => {
158+
it("safe.get surfaces network errors as ok=false, kind=network with raw Error", async () => {
151159
const driver = {
152160
name: "boom",
153161
request: async (): Promise<Response> => {
@@ -160,10 +168,49 @@ describe("createMisinaTyped — .safe namespace", () => {
160168

161169
expect(result.ok).toBe(false)
162170
if (!result.ok) {
163-
expect(result.error.status).toBe(0)
164-
expect(result.response).toBeUndefined()
171+
expect(result.kind).toBe("network")
172+
if (result.kind === "network") {
173+
expectTypeOf(result.error).toEqualTypeOf<Error>()
174+
expect(result.error).toBeInstanceOf(Error)
175+
// The driver throws TypeError("fetch failed"), which misina maps
176+
// to a NetworkError before bubbling. Either message indicates the
177+
// raw Error survived the .safe wrapper.
178+
expect(result.error.message).toMatch(/fetch failed|Network request/)
179+
expect(result.response).toBeUndefined()
180+
}
165181
}
166182
})
183+
184+
it("safe.get on HTTPError 429 has kind=http, error.status === 429, and a Response", async () => {
185+
const driver = mockDriverFactory({
186+
response: jsonResponse({ retryAfter: 5 }, 429),
187+
})
188+
const api = createMisinaTyped<Api>({ driver, retry: 0, baseURL: "https://api.test" })
189+
190+
const result = await api.safe.get("/users/:id", { params: { id: "x" } })
191+
192+
expect(result.ok).toBe(false)
193+
if (!result.ok) {
194+
expect(result.kind).toBe("http")
195+
if (result.kind === "http") {
196+
expect(result.error.status).toBe(429)
197+
expect(result.response).toBeInstanceOf(Response)
198+
expect(result.response.status).toBe(429)
199+
}
200+
}
201+
})
202+
203+
it("kind discriminator narrows error shape at compile time", async () => {
204+
type R = ResponsesOf<Api["GET /users/:id"]>
205+
type Err = TypedSafeErr<R>
206+
type HttpBranch = Extract<Err, { kind: "http" }>
207+
type NetBranch = Extract<Err, { kind: "network" }>
208+
209+
expectTypeOf<HttpBranch["error"]["status"]>().toEqualTypeOf<404 | 429>()
210+
expectTypeOf<HttpBranch["response"]>().toEqualTypeOf<Response>()
211+
expectTypeOf<NetBranch["error"]>().toEqualTypeOf<Error>()
212+
expectTypeOf<NetBranch["response"]>().toEqualTypeOf<undefined>()
213+
})
167214
})
168215

169216
describe("ResponsesOf / SuccessBodyOf — type-only narrowing", () => {
@@ -183,12 +230,18 @@ describe("ResponsesOf / SuccessBodyOf — type-only narrowing", () => {
183230
expectTypeOf<S>().toEqualTypeOf<User | { id: string }>()
184231
})
185232

186-
it("TypedSafeResult is a discriminated union by ok", () => {
233+
it("TypedSafeResult is a discriminated union by ok, then by kind on the error side", () => {
187234
type R = { 200: User; 404: NotFound }
188235
type T = TypedSafeResult<R>
189236
type Ok = Extract<T, { ok: true }>
190237
type Err = Extract<T, { ok: false }>
238+
type ErrHttp = Extract<Err, { kind: "http" }>
239+
type ErrNet = Extract<Err, { kind: "network" }>
240+
191241
expectTypeOf<Ok["data"]>().toEqualTypeOf<User>()
192-
expectTypeOf<Err["error"]>().toEqualTypeOf<{ status: 404; data: NotFound }>()
242+
expectTypeOf<ErrHttp["error"]>().toEqualTypeOf<{ status: 404; data: NotFound }>()
243+
expectTypeOf<ErrHttp>().toEqualTypeOf<TypedSafeHttpErr<R>>()
244+
expectTypeOf<ErrNet>().toEqualTypeOf<TypedSafeNetworkErr>()
245+
expectTypeOf<ErrNet["error"]>().toEqualTypeOf<Error>()
193246
})
194247
})

test/types.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
type MisinaResult,
2727
type TypedMisina,
2828
} from "../src/index.ts"
29-
import type { TypedSafeResult } from "../src/typed.ts"
29+
import type { TypedSafeHttpErr, TypedSafeNetworkErr, TypedSafeResult } from "../src/typed.ts"
3030
import { tracing } from "../src/tracing/index.ts"
3131

3232
describe("createMisina return type", () => {
@@ -173,6 +173,28 @@ describe("TypedMisina.safe — typed per-status-code result", () => {
173173
>()
174174
})
175175

176+
it("TypedSafeResult error branch splits on `kind` into http vs network", () => {
177+
type R = {
178+
200: { id: string; name: string }
179+
404: { message: string }
180+
429: { retryAfter: number }
181+
}
182+
type Result = TypedSafeResult<R>
183+
type Err = Extract<Result, { ok: false }>
184+
type Http = Extract<Err, { kind: "http" }>
185+
type Net = Extract<Err, { kind: "network" }>
186+
187+
// The HTTP branch carries the per-status discriminated union and a
188+
// real Response. The network branch carries a raw Error and no
189+
// Response. status is never widened to `number`.
190+
expectTypeOf<Http>().toEqualTypeOf<TypedSafeHttpErr<R>>()
191+
expectTypeOf<Net>().toEqualTypeOf<TypedSafeNetworkErr>()
192+
expectTypeOf<Http["error"]["status"]>().toEqualTypeOf<404 | 429>()
193+
expectTypeOf<Http["response"]>().toEqualTypeOf<Response>()
194+
expectTypeOf<Net["error"]>().toEqualTypeOf<Error>()
195+
expectTypeOf<Net["response"]>().toEqualTypeOf<undefined>()
196+
})
197+
176198
it("TypedMisina exposes raw + safe alongside throwing methods", () => {
177199
type T = TypedMisina<Api>
178200
expectTypeOf<T["raw"]>().toEqualTypeOf<Misina>()

0 commit comments

Comments
 (0)