Skip to content

Commit e97c232

Browse files
feat(#36): safe() no-throw mode — { ok, data, error, response } discriminated result
ofetch #370. Hot UI code can't union-type a try/catch — TS treats the catch as unknown. Go-style result tuple gives both branches first-class types via a discriminated union: type MisinaResult<T, E> = | { ok: true; data: T; response: MisinaResponse<T> } | { ok: false; error: HTTPError<E> | Error; response?: MisinaResponse<unknown> } API: const result = await misina.safe.get<User, ApiError>('/users/42') if (result.ok) { result.data // User result.response // MisinaResponse<User> } else { result.error // HTTPError<ApiError> | Error result.response?.status // available for HTTPError; undefined on NetworkError } Every Misina shorthand is mirrored on .safe — get/post/put/patch/delete/ head/options/query/request. No try/catch needed at call sites. Implementation: - MisinaResult<T, E> discriminated union in types.ts - SafeMisina interface (parallel to Misina) - misina.safe — synthesized from the existing callable() pipeline, catching errors into the result shape - Closes #36 Audit pass 51 (test/safe-mode.test.ts, 6 tests): ok: true with typed data, ok: false with typed error body via second generic, network-error case (response: undefined), POST with body, no-throw guarantee, default ok: true preserves T. Suite: 435 / 435 (was 429). Lint+typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5d67fea commit e97c232

3 files changed

Lines changed: 239 additions & 0 deletions

File tree

src/misina.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import type {
2525
MisinaResolvedOptions,
2626
MisinaResponse,
2727
MisinaResponsePromise,
28+
MisinaResult,
29+
SafeMisina,
2830
} from "./types.ts"
2931

3032
// Methods that never accept a request body. DELETE is intentionally absent —
@@ -309,6 +311,58 @@ export function createMisina(defaults: MisinaOptions = {}): Misina {
309311
): MisinaResponsePromise<T> => callable<T>(url, { ...init, method, body })
310312
}
311313

314+
async function safeCall<T, E>(
315+
method: HttpMethod,
316+
input: string,
317+
body: unknown,
318+
init?: MisinaRequestInit,
319+
): Promise<MisinaResult<T, E>> {
320+
try {
321+
const response = await callable<T>(input, { ...init, method, body })
322+
return { ok: true, data: response.data, response }
323+
} catch (e) {
324+
const error = e as Error
325+
const response = (error as { response?: Response }).response as Response | undefined
326+
// For HTTPError we already have the parsed body — surface a
327+
// synthesized MisinaResponse<unknown> so callers can read headers.
328+
let outResponse: MisinaResponse<unknown> | undefined
329+
if (response instanceof Response) {
330+
outResponse = {
331+
data: (error as { data?: unknown }).data,
332+
status: response.status,
333+
statusText: response.statusText,
334+
headers: Object.fromEntries(response.headers),
335+
url: response.url,
336+
type: response.type,
337+
timings: { start: 0, responseStart: 0, end: 0, total: 0 },
338+
raw: response,
339+
}
340+
}
341+
return { ok: false, response: outResponse, error: error as HTTPError<E> | Error }
342+
}
343+
}
344+
345+
const safe: SafeMisina = {
346+
request: <T = unknown, E = unknown>(input: string, init?: MisinaRequestInit) =>
347+
safeCall<T, E>((init?.method ?? "GET") as HttpMethod, input, init?.body, init),
348+
get: <T = unknown, E = unknown>(url: string, init?: MisinaRequestInit) =>
349+
safeCall<T, E>("GET", url, undefined, init),
350+
post: <T = unknown, E = unknown>(url: string, body?: unknown, init?: MisinaRequestInit) =>
351+
safeCall<T, E>("POST", url, body, init),
352+
put: <T = unknown, E = unknown>(url: string, body?: unknown, init?: MisinaRequestInit) =>
353+
safeCall<T, E>("PUT", url, body, init),
354+
patch: <T = unknown, E = unknown>(url: string, body?: unknown, init?: MisinaRequestInit) =>
355+
safeCall<T, E>("PATCH", url, body, init),
356+
delete: <T = unknown, E = unknown>(url: string, init?: MisinaRequestInit) =>
357+
safeCall<T, E>("DELETE", url, undefined, init),
358+
head: <T = unknown, E = unknown>(url: string, init?: MisinaRequestInit) =>
359+
safeCall<T, E>("HEAD", url, undefined, init),
360+
options: <T = unknown, E = unknown>(url: string, init?: MisinaRequestInit) =>
361+
safeCall<T, E>("OPTIONS", url, undefined, init),
362+
query: <T = unknown, E = unknown>(url: string, body?: unknown, init?: MisinaRequestInit) =>
363+
safeCall<T, E>("QUERY", url, body, init),
364+
}
365+
312366
const misina: Misina = {
313367
extend: (input) => {
314368
const child = typeof input === "function" ? input(defaults) : input
@@ -324,6 +378,7 @@ export function createMisina(defaults: MisinaOptions = {}): Misina {
324378
head: bind("HEAD"),
325379
options: bind("OPTIONS"),
326380
query: bindWithBody("QUERY"),
381+
safe,
327382
}
328383

329384
return misina

src/types.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,67 @@ export interface ResponseTimings {
387387

388388
export type CatchMatcher = number | number[] | string | ((error: unknown) => boolean)
389389

390+
/**
391+
* Discriminated result type returned by `misina.safe.*` methods. Uses Go-style
392+
* branching so both success and failure paths get full type safety.
393+
*/
394+
export type MisinaResult<T, E = unknown> =
395+
| { ok: true; data: T; response: MisinaResponse<T>; error?: undefined }
396+
| {
397+
ok: false
398+
data?: undefined
399+
response: MisinaResponse<unknown> | undefined
400+
error: HTTPError<E> | Error
401+
}
402+
403+
/**
404+
* Same surface as `Misina` but every method returns a `MisinaResult` instead
405+
* of throwing. Perfect for hot UI code where TypeScript needs to discriminate
406+
* branches without `try/catch` widening to `unknown`.
407+
*/
408+
export interface SafeMisina {
409+
request: <T = unknown, E = unknown>(
410+
input: string,
411+
init?: MisinaRequestInit,
412+
) => Promise<MisinaResult<T, E>>
413+
get: <T = unknown, E = unknown>(
414+
url: string,
415+
init?: MisinaRequestInit,
416+
) => Promise<MisinaResult<T, E>>
417+
post: <T = unknown, E = unknown>(
418+
url: string,
419+
body?: unknown,
420+
init?: MisinaRequestInit,
421+
) => Promise<MisinaResult<T, E>>
422+
put: <T = unknown, E = unknown>(
423+
url: string,
424+
body?: unknown,
425+
init?: MisinaRequestInit,
426+
) => Promise<MisinaResult<T, E>>
427+
patch: <T = unknown, E = unknown>(
428+
url: string,
429+
body?: unknown,
430+
init?: MisinaRequestInit,
431+
) => Promise<MisinaResult<T, E>>
432+
delete: <T = unknown, E = unknown>(
433+
url: string,
434+
init?: MisinaRequestInit,
435+
) => Promise<MisinaResult<T, E>>
436+
head: <T = unknown, E = unknown>(
437+
url: string,
438+
init?: MisinaRequestInit,
439+
) => Promise<MisinaResult<T, E>>
440+
options: <T = unknown, E = unknown>(
441+
url: string,
442+
init?: MisinaRequestInit,
443+
) => Promise<MisinaResult<T, E>>
444+
query: <T = unknown, E = unknown>(
445+
url: string,
446+
body?: unknown,
447+
init?: MisinaRequestInit,
448+
) => Promise<MisinaResult<T, E>>
449+
}
450+
390451
export interface MisinaResponsePromise<T, E = unknown> extends Promise<MisinaResponse<T>> {
391452
/**
392453
* Recover from specific errors. Matcher can be a status code, an array of
@@ -452,6 +513,12 @@ export interface Misina {
452513
body?: unknown,
453514
init?: MisinaRequestInit,
454515
) => MisinaResponsePromise<T, E>
516+
/**
517+
* No-throw companion. `misina.safe.get<T, E>(url)` returns
518+
* `Promise<MisinaResult<T, E>>` — a discriminated `{ ok, data, error,
519+
* response }` object so both branches are type-safe at the call site.
520+
*/
521+
safe: SafeMisina
455522
}
456523

457524
/**

test/safe-mode.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { describe, expect, it } from "vitest"
2+
import { createMisina, HTTPError } from "../src/index.ts"
3+
4+
describe("misina.safe — no-throw mode", () => {
5+
it("ok: true on 200 with typed data", async () => {
6+
const driver = {
7+
name: "p",
8+
request: async () =>
9+
new Response('{"name":"alice"}', { headers: { "content-type": "application/json" } }),
10+
}
11+
const m = createMisina({ driver, retry: 0 })
12+
13+
const result = await m.safe.get<{ name: string }>("https://api.test/users/1")
14+
expect(result.ok).toBe(true)
15+
if (result.ok) {
16+
expect(result.data.name).toBe("alice")
17+
expect(result.response.status).toBe(200)
18+
expect(result.error).toBeUndefined()
19+
}
20+
})
21+
22+
it("ok: false on HTTPError; error is typed via second generic", async () => {
23+
interface ApiError {
24+
code: string
25+
message: string
26+
}
27+
const driver = {
28+
name: "p",
29+
request: async () =>
30+
new Response(JSON.stringify({ code: "E_FORBIDDEN", message: "nope" }), {
31+
status: 403,
32+
headers: { "content-type": "application/json" },
33+
}),
34+
}
35+
const m = createMisina({ driver, retry: 0 })
36+
37+
const result = await m.safe.get<{ name: string }, ApiError>("https://api.test/users/1")
38+
expect(result.ok).toBe(false)
39+
if (!result.ok) {
40+
expect(result.error).toBeInstanceOf(HTTPError)
41+
const httpErr = result.error as HTTPError<ApiError>
42+
expect(httpErr.data.code).toBe("E_FORBIDDEN")
43+
expect(result.response?.status).toBe(403)
44+
}
45+
})
46+
47+
it("ok: false on network error; response is undefined", async () => {
48+
const driver = {
49+
name: "broken",
50+
request: async () => {
51+
throw Object.assign(new TypeError("fetch failed"), { name: "TypeError" })
52+
},
53+
}
54+
const m = createMisina({ driver, retry: 0 })
55+
56+
const result = await m.safe.get("https://api.test/")
57+
expect(result.ok).toBe(false)
58+
if (!result.ok) {
59+
expect(result.error.name).toBe("NetworkError")
60+
expect(result.response).toBeUndefined()
61+
}
62+
})
63+
64+
it("safe.post sends body, returns success on 201", async () => {
65+
let captured: Request | undefined
66+
const driver = {
67+
name: "p",
68+
request: async (req: Request) => {
69+
captured = req
70+
return new Response('{"id":"42"}', {
71+
status: 201,
72+
headers: { "content-type": "application/json" },
73+
})
74+
},
75+
}
76+
const m = createMisina({ driver, retry: 0 })
77+
78+
const result = await m.safe.post<{ id: string }>("https://api.test/users", { name: "alice" })
79+
expect(result.ok).toBe(true)
80+
if (result.ok) {
81+
expect(result.data.id).toBe("42")
82+
}
83+
expect(captured?.method).toBe("POST")
84+
expect(await captured?.text()).toBe('{"name":"alice"}')
85+
})
86+
87+
it("safe doesn't throw — discriminated union covers all branches", async () => {
88+
const driver = {
89+
name: "p",
90+
request: async () => new Response("err", { status: 500 }),
91+
}
92+
const m = createMisina({ driver, retry: 0 })
93+
94+
// Critical: this never enters a `catch` — TypeScript sees branches.
95+
let entered = false
96+
const result = await m.safe.get("https://api.test/")
97+
entered = true
98+
expect(entered).toBe(true)
99+
expect(result.ok).toBe(false)
100+
})
101+
102+
it("default ok: true keeps the parsed body type", async () => {
103+
const driver = {
104+
name: "p",
105+
request: async () =>
106+
new Response("[1,2,3]", { headers: { "content-type": "application/json" } }),
107+
}
108+
const m = createMisina({ driver, retry: 0 })
109+
110+
const result = await m.safe.get<number[]>("https://api.test/list")
111+
if (result.ok) {
112+
expect(result.data).toEqual([1, 2, 3])
113+
} else {
114+
throw new Error("should not be reached")
115+
}
116+
})
117+
})

0 commit comments

Comments
 (0)