Skip to content

Commit c75213f

Browse files
committed
feat: improved error diagnostics
Richer errors Issue now has optional received; ValidationError / UnexpectedError / FnError all serialize via toJSON() Schema messages include value previews (types, email/URL/regex/check, unions with branch N: labels) Paths name the door: fn.input…, model.create… HTTP edge encodeError → ValidationError 400, FnError status/422, UnexpectedError 500 createHandler / handler return JSON bodies instead of rethrowing http.err status is stamped onto FnError at mint (read from the original declaration, since asType drops the symbol) Breaking: input paths now include .input (e.g. fnt.multi.input.a).
1 parent 2a11d49 commit c75213f

14 files changed

Lines changed: 430 additions & 75 deletions

File tree

packages/experimental/README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,19 @@ What this buys, all Effect-inspired but with plain functions:
109109
- **Failure vs defect.** A thrown `c.error(...)` is a `FnError` - a domain outcome, tagged, serializable (`{ tag, data, trail }` survives a wire). Once a fn declares `errors`, any *untagged* throw escaping its body is a bug and comes out as `UnexpectedError` with the original on `cause`. Callers never string-match to tell the two apart.
110110
- **Typed recovery.** `fn.try(input)` returns `{ ok: true, value } | { ok: false, error }` where `error` is the union of declared errors - TS narrows on `error.tag`. Defects and contract violations still throw. `FnErrors<typeof fn>` gives the union for catch sites.
111111
- **The trail.** As an error crosses fn frames it collects their keys - `["audit.log", "profile.update", "capability.exec"]` - origin first, so nothing about where a failure started is ever lost.
112-
- **All issues, not the first.** Validation collects every bad field / tuple position in one `ValidationError.issues` list.
112+
- **All issues, not the first.** Validation collects every bad field / tuple position in one `ValidationError.issues` list. Paths name the contract door (`sign_in.email.input.email`), and each issue can carry a `received` preview:
113+
114+
```ts
115+
// sign_in.email.input.email: expected an email address, received "nope"
116+
// sign_in.email.input.password: expected string, received number (1)
117+
err.issues;
118+
// [
119+
// { path: "sign_in.email.input.email", message: "expected an email address, received \"nope\"", received: "\"nope\"" },
120+
// { path: "sign_in.email.input.password", message: "expected string, received number (1)", received: "1" },
121+
// ]
122+
```
123+
124+
At the HTTP edge (`createHandler` / `handler`), `ValidationError` becomes `400` with that JSON body, `FnError` uses `http.err` status (or `422`), and `UnexpectedError` becomes `500`.
113125

114126
### vars
115127

packages/experimental/src/error.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,39 @@
1-
export type Issue = { path: string; message: string };
1+
export type Issue = {
2+
path: string;
3+
message: string;
4+
/** Truncated, JSON-safe preview of the bad value. */
5+
received?: string;
6+
};
27

38
/** A contract violation - input, output, requires, provides. Carries
49
* EVERY issue found in the pass that threw it, not just the first;
510
* `message` lists them all. */
611
export class ValidationError extends Error {
712
public path: string;
813
public issues: Issue[];
9-
constructor(path: string, message: string, issues?: Issue[]) {
14+
constructor(
15+
path: string,
16+
message: string,
17+
issues?: Issue[],
18+
options?: ErrorOptions,
19+
) {
1020
const all = issues?.length ? issues : [{ path, message }];
11-
super(all.map((issue) => `${issue.path}: ${issue.message}`).join("; "));
21+
super(
22+
all.map((issue) => `${issue.path}: ${issue.message}`).join("; "),
23+
options,
24+
);
1225
this.name = "ValidationError";
1326
this.path = all[0]?.path ?? path;
1427
this.issues = all;
1528
}
29+
toJSON() {
30+
return {
31+
name: this.name,
32+
message: this.message,
33+
path: this.path,
34+
issues: this.issues,
35+
};
36+
}
1637
}
1738

1839
/**
@@ -21,27 +42,33 @@ export class ValidationError extends Error {
2142
* fn's `errors` schema. The `tag` is the discriminant callers narrow on;
2243
* `trail` records the fn that threw, then every frame it crossed.
2344
* Serializes as data, so it survives a remote boundary intact.
45+
* Optional `status` is set when the declaration carried HTTP metadata
46+
* (`http.err`); the edge uses it without looking the map back up.
2447
*/
2548
export class FnError<
2649
Tag extends string = string,
2750
Data = unknown,
2851
> extends Error {
2952
public trail: string[];
53+
public status?: number;
3054
constructor(
3155
public tag: Tag,
3256
public data: Data,
3357
fn: string,
58+
status?: number,
3459
) {
3560
super(`${fn}: ${tag}`);
3661
this.name = "FnError";
3762
this.trail = [fn];
63+
if (status !== undefined) this.status = status;
3864
}
3965
toJSON() {
4066
return {
4167
name: this.name,
4268
tag: this.tag as Tag,
4369
data: this.data,
4470
trail: this.trail,
71+
...(this.status !== undefined ? { status: this.status } : {}),
4572
};
4673
}
4774
}
@@ -63,6 +90,19 @@ export class UnexpectedError extends Error {
6390
this.name = "UnexpectedError";
6491
this.trail = [fn];
6592
}
93+
toJSON() {
94+
const cause = this.cause;
95+
return {
96+
name: this.name,
97+
message: this.message,
98+
trail: this.trail,
99+
...(cause instanceof Error
100+
? { cause: { name: cause.name, message: cause.message } }
101+
: cause !== undefined
102+
? { cause: { message: String(cause) } }
103+
: {}),
104+
};
105+
}
66106
}
67107

68108
/** Plugin/transport control that is neither a domain refusal nor a defect.

packages/experimental/src/fn.test.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,7 @@ describe("tuple input - positional args", () => {
416416
{ input: [v.string({ min: 3 })] },
417417
(c) => c.input[0],
418418
);
419-
expect(() => f("ab")).toThrow(/fnt\.pos\[0\]/);
419+
expect(() => f("ab")).toThrow(/fnt\.pos\.input\[0\]/);
420420
});
421421

422422
it("used fns forward positional args and still share the scope", async () => {
@@ -459,7 +459,7 @@ describe("fn as input schema", () => {
459459
(c) => c.input.execute({ n: "x" } as never),
460460
);
461461
expect(() => run({ execute: (i) => i.n })).toThrow(
462-
/fnt\.runner\.execute\(\)/,
462+
/fnt\.runner\.input\.execute\(\)/,
463463
);
464464
});
465465

@@ -975,7 +975,17 @@ describe("multi-issue validation", () => {
975975
expect(thrown).toBeInstanceOf(ValidationError);
976976
const err = thrown as ValidationError;
977977
expect(err.issues).toHaveLength(2);
978-
expect(err.message).toMatch(/fnt\.multi\.a.*fnt\.multi\.b/s);
978+
expect(err.message).toMatch(
979+
/fnt\.multi\.input\.a.*fnt\.multi\.input\.b/s,
980+
);
981+
expect(err.issues[0]?.received).toBe("1");
982+
expect(err.issues[1]?.received).toBe('"x"');
983+
expect(JSON.parse(JSON.stringify(err))).toEqual({
984+
name: "ValidationError",
985+
message: err.message,
986+
path: "fnt.multi.input.a",
987+
issues: err.issues,
988+
});
979989
}
980990
});
981991

@@ -991,8 +1001,8 @@ describe("multi-issue validation", () => {
9911001
} catch (thrown) {
9921002
const err = thrown as ValidationError;
9931003
expect(err.issues.map((issue) => issue.path)).toEqual([
994-
"fnt.multiArr.tags[0]",
995-
"fnt.multiArr.tags[2]",
1004+
"fnt.multiArr.input.tags[0]",
1005+
"fnt.multiArr.input.tags[2]",
9961006
]);
9971007
}
9981008
});
@@ -1009,8 +1019,8 @@ describe("multi-issue validation", () => {
10091019
} catch (thrown) {
10101020
const err = thrown as ValidationError;
10111021
expect(err.issues.map((issue) => issue.path)).toEqual([
1012-
"fnt.multiPos[0]",
1013-
"fnt.multiPos[1]",
1022+
"fnt.multiPos.input[0]",
1023+
"fnt.multiPos.input[1]",
10141024
]);
10151025
}
10161026
});

packages/experimental/src/fn.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -905,7 +905,7 @@ const defineFn = (
905905
const result = validate(
906906
asType(def),
907907
(input as unknown[])[index],
908-
`${key}[${index}]`,
908+
`${key}.input[${index}]`,
909909
);
910910
if (isThenable(result)) {
911911
return result.then(
@@ -955,7 +955,7 @@ const defineFn = (
955955
}
956956
return options.input === undefined
957957
? input
958-
: validate(asType(options.input), input, key);
958+
: validate(asType(options.input), input, `${key}.input`);
959959
};
960960

961961
const applyInputExtensions = (parsed: unknown) => {
@@ -1022,10 +1022,18 @@ const defineFn = (
10221022
: `"${key}" declares no errors`,
10231023
);
10241024
}
1025+
// HTTP status lives on the ORIGINAL declaration (`http.err`);
1026+
// `asType` copies enumerable fields only, so read it there.
1027+
const meta = (
1028+
declaredErrors?.[tag] as
1029+
| Record<symbol, { status?: number } | undefined>
1030+
| undefined
1031+
)?.[Symbol.for("better-call:http.err")];
10251032
return new FnError(
10261033
tag,
10271034
validate(schema, data ?? {}, `${key}.errors.${tag}`),
10281035
key,
1036+
meta?.status,
10291037
);
10301038
},
10311039
[STORE]: cells,

packages/experimental/src/plugins/http/attrs.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,15 @@ describe("http field attrs", () => {
100100
await expect(fromJsonBody(smuggle, v.object(shape))).rejects.toThrow(
101101
ValidationError,
102102
);
103+
104+
const badJson = new Request("https://example.com", {
105+
method: "POST",
106+
headers: { "content-type": "application/json" },
107+
body: "{",
108+
});
109+
await expect(fromJsonBody(badJson, v.object(shape))).rejects.toThrow(
110+
/expected a JSON body \(/,
111+
);
103112
});
104113

105114
it("clientSchema and rejectServerOnly recurse into union arms", () => {

packages/experimental/src/plugins/http/attrs.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,13 @@ export const fromJsonBody = async <S>(
120120
let body: unknown;
121121
try {
122122
body = await request.json();
123-
} catch {
124-
throw new ValidationError(path, "expected a JSON body");
123+
} catch (cause) {
124+
throw new ValidationError(
125+
path,
126+
`expected a JSON body (${cause instanceof Error ? cause.message : String(cause)})`,
127+
undefined,
128+
{ cause },
129+
);
125130
}
126131
return await wireInput(schema, body, path);
127132
};

packages/experimental/src/plugins/http/error.test.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { describe, expect, expectTypeOf, it } from "vitest";
2-
import { FnError, v } from "../../index";
2+
import { FnError, UnexpectedError, ValidationError, v } from "../../index";
33
import {
44
applyError,
5+
encodeError,
56
err,
67
errorStatus,
78
type HttpResponse,
@@ -65,6 +66,7 @@ describe("http.err - status on declared errors", () => {
6566
expect(bad.error).toBeInstanceOf(FnError);
6667
expect(bad.error.tag).toBe("invalid_credentials");
6768
expect(bad.error.data).toEqual({ attempts: 3 });
69+
expect(bad.error.status).toBe(401);
6870
if (bad.error.tag === "invalid_credentials") {
6971
expectTypeOf(bad.error.data).toEqualTypeOf<{
7072
readonly attempts: number;
@@ -77,6 +79,7 @@ describe("http.err - status on declared errors", () => {
7779
if (!gone.ok) {
7880
expect(gone.error.tag).toBe("gone");
7981
expect(gone.error.data).toEqual({});
82+
expect(gone.error.status).toBe(410);
8083
}
8184

8285
expect(() =>
@@ -133,6 +136,12 @@ describe("http.err - status on declared errors", () => {
133136
expect(response.status).toBe(401);
134137
});
135138

139+
it("applyError uses FnError.status when the map is absent", () => {
140+
const response: HttpResponse = { headers: new Headers() };
141+
applyError(response, undefined, { tag: "denied", status: 403 });
142+
expect(response.status).toBe(403);
143+
});
144+
136145
it("applyError leaves res.status alone for plain error tags", () => {
137146
const response = { headers: new Headers(), status: 200 };
138147
applyError(response, { plain: { reason: v.string() } }, { tag: "plain" });
@@ -144,3 +153,88 @@ describe("http.err - status on declared errors", () => {
144153
expect(http.statusOf(http.err(418))).toBe(418);
145154
});
146155
});
156+
157+
describe("encodeError + createHandler JSON bodies", () => {
158+
it("encodeError maps ValidationError to 400", () => {
159+
const error = new ValidationError(
160+
"body.n",
161+
'expected number, received string ("x")',
162+
[
163+
{
164+
path: "body.n",
165+
message: 'expected number, received string ("x")',
166+
received: '"x"',
167+
},
168+
],
169+
);
170+
expect(encodeError(error)).toEqual({
171+
status: 400,
172+
body: error.toJSON(),
173+
});
174+
});
175+
176+
it("encodeError maps FnError status or defaults to 422", () => {
177+
const stamped = new FnError("denied", {}, "f", 403);
178+
expect(encodeError(stamped)?.status).toBe(403);
179+
const plain = new FnError("denied", {}, "f");
180+
expect(encodeError(plain)?.status).toBe(422);
181+
});
182+
183+
it("encodeError maps UnexpectedError to 500 without a cause stack", () => {
184+
const unexpected = new UnexpectedError(new Error("boom"), "f");
185+
expect(encodeError(unexpected)).toEqual({
186+
status: 500,
187+
body: {
188+
name: "UnexpectedError",
189+
message: "f: unexpected - boom",
190+
trail: ["f"],
191+
cause: { name: "Error", message: "boom" },
192+
},
193+
});
194+
});
195+
196+
it("createHandler returns 400 JSON for ValidationError", async () => {
197+
const fetch = http.createHandler(() => {
198+
throw new ValidationError(
199+
"body.email",
200+
'expected an email address, received "x"',
201+
[
202+
{
203+
path: "body.email",
204+
message: 'expected an email address, received "x"',
205+
received: '"x"',
206+
},
207+
],
208+
);
209+
});
210+
const response = await fetch(new Request("http://x.test/"));
211+
expect(response.status).toBe(400);
212+
await expect(response.json()).resolves.toMatchObject({
213+
name: "ValidationError",
214+
path: "body.email",
215+
issues: [{ path: "body.email", received: '"x"' }],
216+
});
217+
});
218+
219+
it("createHandler returns declared status JSON for FnError", async () => {
220+
const deny = app.fn(
221+
"httpt.err.deny",
222+
{ errors: { denied: err(403) } },
223+
(c) => {
224+
throw c.error("denied");
225+
},
226+
);
227+
const fetch = http.createHandler(() => {
228+
throw deny();
229+
});
230+
const response = await fetch(new Request("http://x.test/"));
231+
expect(response.status).toBe(403);
232+
await expect(response.json()).resolves.toEqual({
233+
name: "FnError",
234+
tag: "denied",
235+
data: {},
236+
trail: ["httpt.err.deny"],
237+
status: 403,
238+
});
239+
});
240+
});

0 commit comments

Comments
 (0)