@gusnips/http 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gustavo Salomé
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # @gusnips/http
2
+
3
+ The two shapes your API answers with, written down once as types. No dependencies, and nothing
4
+ tied to a framework or a platform — your server, your browser client and your SDK all compile
5
+ this same file.
6
+
7
+ ```bash
8
+ bun add @gusnips/http
9
+ ```
10
+
11
+ ```ts
12
+ import type { ApiSuccess } from "@gusnips/http";
13
+
14
+ type Me = ApiSuccess<{ id: string; name: string }>;
15
+ // → { data: { id: string; name: string }; meta?: PaginationMeta }
16
+ ```
17
+
18
+ ## The envelope
19
+
20
+ Every route answers one of two things, and never anything else:
21
+
22
+ ```jsonc
23
+ // 2xx
24
+ { "data": { "id": "u_1" }, "meta": { "total": 40, "limit": 20, "offset": 0, "hasMore": true } }
25
+
26
+ // anything else
27
+ { "error": { "code": "NOT_FOUND", "message": "No such user" } }
28
+ ```
29
+
30
+ Which means unwrapping and error-shaping happen in one place instead of in every hook.
31
+
32
+ ## The error half has four fields, and each does a different job
33
+
34
+ ```ts
35
+ {
36
+ code: "RATE_LIMITED", // what your code switches on. Stable, machine-facing.
37
+ message: "Too many requests", // English, for logs and curl. Never your primary copy.
38
+ messageKey: "errors.rateLimited", // names the SENTENCE, so the client localizes it
39
+ params: { limit: 100 }, // fills that sentence's blanks
40
+ details: { resetAt: "2026-01-01T00:00:00Z" } // what makes the refusal actionable
41
+ }
42
+ ```
43
+
44
+ The split that matters is `message` versus `messageKey`. The server owns the _condition_; the
45
+ client owns the _prose_. That is what lets an API refuse something without knowing what language
46
+ the person reads, and `details` is what lets the client offer a way forward instead of a dead
47
+ end.
48
+
49
+ ## The codes are yours
50
+
51
+ Everything is generic over your own code union. Two codebases this came from had 30 codes and 16
52
+ codes, overlapping on nine — a code list is an API's vocabulary and belongs to it.
53
+
54
+ ```ts
55
+ import { asErrorCode, isApiError, type ApiError } from "@gusnips/http";
56
+
57
+ type Code = "NOT_FOUND" | "RATE_LIMITED" | "UNKNOWN";
58
+ const CODES = ["NOT_FOUND", "RATE_LIMITED", "UNKNOWN"] as const;
59
+
60
+ if (isApiError<Code>(body)) {
61
+ switch (asErrorCode(CODES, body.error.code, "UNKNOWN")) {
62
+ case "RATE_LIMITED": /* … */
63
+ }
64
+ }
65
+ ```
66
+
67
+ `asErrorCode` exists for one reason: **a code you do not recognise means the server is newer than
68
+ the tab.** A deploy landed ahead of the bundle someone is still running. That is not a parse
69
+ failure and must not throw, so it falls back instead.
70
+
71
+ ## Status codes
72
+
73
+ Write the map at the call site and let `satisfies` do the work:
74
+
75
+ ```ts
76
+ export const ERROR_STATUS = {
77
+ NOT_FOUND: 404,
78
+ RATE_LIMITED: 429,
79
+ } as const satisfies Record<Code, number>;
80
+ ```
81
+
82
+ That one line is the point: adding a code to the union without giving it a status becomes a build
83
+ error, instead of a route answering 500 for a refusal it knew how to explain.
84
+
85
+ MIT · part of [frontkit](https://github.com/gusnips/frontkit)
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The HTTP envelope an API and its clients agree on.
3
+ *
4
+ * Every route answers one of two shapes — `{data, meta?}` or `{error:{…}}` — so unwrapping
5
+ * and error-shaping belong in one place rather than in every hook. This module is that
6
+ * place, and it is deliberately types plus three tiny functions: the API server produces
7
+ * the envelope, the browser client consumes it, the SDK re-exports it, and an agent reads
8
+ * it. All four compile this file unchanged, which is why it carries no framework and no
9
+ * platform (see `scripts/check-purity.ts`).
10
+ *
11
+ * **The codes are yours, not ours.** Two donor repos had 30 codes and 16 codes respectively,
12
+ * overlapping on nine; the list is an API's vocabulary and belongs to it. Everything here is
13
+ * generic over that union, so a product declares its own codes once and gets the envelope,
14
+ * the status map and the narrowing for free.
15
+ */
16
+ /** Where a list route puts its counts. Identical in every donor, to the field. */
17
+ export interface PaginationMeta {
18
+ total: number;
19
+ limit: number;
20
+ offset: number;
21
+ hasMore: boolean;
22
+ }
23
+ /**
24
+ * Success envelope — every 2xx JSON body is exactly this shape.
25
+ *
26
+ * `M` is the meta a route may attach. It defaults to {@link PaginationMeta} because a list
27
+ * route is the common case; a metered API passes its own union instead (one donor carries
28
+ * cache/credit counters there, and its rule — a cache hit is free and visibly so — is the
29
+ * kind of thing that must stay in that product).
30
+ */
31
+ export interface ApiSuccess<T, M = PaginationMeta> {
32
+ data: T;
33
+ meta?: M;
34
+ }
35
+ /**
36
+ * Error envelope — byte-compatible with a server's `AppError.toJSON()`.
37
+ *
38
+ * The four fields are not decoration, and every donor arrived at the same four:
39
+ *
40
+ * - `code` is what a client switches on. Machine-oriented and stable.
41
+ * - `message` is English, for logs, `curl` output and agents. It is the fallback a client
42
+ * shows when it cannot do better — never the primary copy for a person.
43
+ * - `messageKey` names the SENTENCE, so a client can localize or re-word a refusal without
44
+ * the server knowing any language. The server owns the condition; the client owns the prose.
45
+ * - `params` fills that sentence's blanks, and `details` carries what makes a refusal
46
+ * ACTIONABLE — the `resetAt` on a 429, the plan that lifts a 402. A refusal a caller cannot
47
+ * act on is a dead end, which is the thing the whole product rule exists to prevent.
48
+ */
49
+ export interface ApiError<Code extends string = string> {
50
+ error: {
51
+ code: Code;
52
+ /** English fallback — for logs, curl output and agents. Not shown when `messageKey` resolves. */
53
+ message: string;
54
+ /** Stable key naming the sentence, for a client that localizes or brands its copy. */
55
+ messageKey?: string;
56
+ /** Interpolation values for `messageKey`. */
57
+ params?: Record<string, string | number>;
58
+ details?: unknown;
59
+ };
60
+ }
61
+ export type ApiResponse<T, Code extends string = string, M = PaginationMeta> = ApiSuccess<T, M> | ApiError<Code>;
62
+ /** True when a parsed body is the error half of the envelope. */
63
+ export declare function isApiError<Code extends string>(body: unknown): body is ApiError<Code>;
64
+ /**
65
+ * Narrow a code off the wire against the product's own list.
66
+ *
67
+ * An unrecognized code means the SERVER IS NEWER than this client — a deploy that landed
68
+ * ahead of the bundle a tab is still running. That is not a parse failure and must not throw;
69
+ * `fallback` is the honest read of "something failed and this build cannot classify it".
70
+ */
71
+ export declare function asErrorCode<Code extends string>(codes: readonly Code[], value: string, fallback: Code): Code;
72
+ /**
73
+ * A note on the code→status map, which is deliberately NOT a function here.
74
+ *
75
+ * Write it at the call site and let `satisfies` do the work:
76
+ *
77
+ * ```ts
78
+ * export const ERROR_STATUS = {
79
+ * NOT_FOUND: 404,
80
+ * RATE_LIMIT_EXCEEDED: 429,
81
+ * } as const satisfies Record<HttpErrorCode, number>;
82
+ * ```
83
+ *
84
+ * That is one line, and it is the line that matters: adding a code to the union without
85
+ * giving it a status becomes a build error rather than a route answering 500 for a refusal
86
+ * it knew how to explain. A helper wrapping this would add a call and subtract nothing.
87
+ * Keep every such map exhaustive — providerkit learned the same about its error→copy maps,
88
+ * where a widened union fell through to a status code in every locale at once.
89
+ */
90
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,kFAAkF;AAClF,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;CAClB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,UAAU,CAAC,CAAC,EAAE,CAAC,GAAG,cAAc;IAC/C,IAAI,EAAE,CAAC,CAAC;IACR,IAAI,CAAC,EAAE,CAAC,CAAC;CACV;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,QAAQ,CAAC,IAAI,SAAS,MAAM,GAAG,MAAM;IACpD,KAAK,EAAE;QACL,IAAI,EAAE,IAAI,CAAC;QACX,iGAAiG;QACjG,OAAO,EAAE,MAAM,CAAC;QAChB,sFAAsF;QACtF,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,6CAA6C;QAC7C,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC;QACzC,OAAO,CAAC,EAAE,OAAO,CAAC;KACnB,CAAC;CACH;AAED,MAAM,MAAM,WAAW,CAAC,CAAC,EAAE,IAAI,SAAS,MAAM,GAAG,MAAM,EAAE,CAAC,GAAG,cAAc,IACzE,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC;AAEpC,iEAAiE;AACjE,wBAAgB,UAAU,CAAC,IAAI,SAAS,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,IAAI,IAAI,QAAQ,CAAC,IAAI,CAAC,CAQrF;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,IAAI,SAAS,MAAM,EAC7C,KAAK,EAAE,SAAS,IAAI,EAAE,EACtB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,IAAI,GACb,IAAI,CAEN;AAED;;;;;;;;;;;;;;;;;GAiBG"}
package/dist/index.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The HTTP envelope an API and its clients agree on.
3
+ *
4
+ * Every route answers one of two shapes — `{data, meta?}` or `{error:{…}}` — so unwrapping
5
+ * and error-shaping belong in one place rather than in every hook. This module is that
6
+ * place, and it is deliberately types plus three tiny functions: the API server produces
7
+ * the envelope, the browser client consumes it, the SDK re-exports it, and an agent reads
8
+ * it. All four compile this file unchanged, which is why it carries no framework and no
9
+ * platform (see `scripts/check-purity.ts`).
10
+ *
11
+ * **The codes are yours, not ours.** Two donor repos had 30 codes and 16 codes respectively,
12
+ * overlapping on nine; the list is an API's vocabulary and belongs to it. Everything here is
13
+ * generic over that union, so a product declares its own codes once and gets the envelope,
14
+ * the status map and the narrowing for free.
15
+ */
16
+ /** True when a parsed body is the error half of the envelope. */
17
+ export function isApiError(body) {
18
+ return (typeof body === "object" &&
19
+ body !== null &&
20
+ "error" in body &&
21
+ typeof body.error === "object" &&
22
+ body.error !== null);
23
+ }
24
+ /**
25
+ * Narrow a code off the wire against the product's own list.
26
+ *
27
+ * An unrecognized code means the SERVER IS NEWER than this client — a deploy that landed
28
+ * ahead of the bundle a tab is still running. That is not a parse failure and must not throw;
29
+ * `fallback` is the honest read of "something failed and this build cannot classify it".
30
+ */
31
+ export function asErrorCode(codes, value, fallback) {
32
+ return codes.find((c) => c === value) ?? fallback;
33
+ }
34
+ /**
35
+ * A note on the code→status map, which is deliberately NOT a function here.
36
+ *
37
+ * Write it at the call site and let `satisfies` do the work:
38
+ *
39
+ * ```ts
40
+ * export const ERROR_STATUS = {
41
+ * NOT_FOUND: 404,
42
+ * RATE_LIMIT_EXCEEDED: 429,
43
+ * } as const satisfies Record<HttpErrorCode, number>;
44
+ * ```
45
+ *
46
+ * That is one line, and it is the line that matters: adding a code to the union without
47
+ * giving it a status becomes a build error rather than a route answering 500 for a refusal
48
+ * it knew how to explain. A helper wrapping this would add a call and subtract nothing.
49
+ * Keep every such map exhaustive — providerkit learned the same about its error→copy maps,
50
+ * where a widened union fell through to a status code in every locale at once.
51
+ */
52
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAqDH,iEAAiE;AACjE,MAAM,UAAU,UAAU,CAAsB,IAAa;IAC3D,OAAO,CACL,OAAO,IAAI,KAAK,QAAQ;QACxB,IAAI,KAAK,IAAI;QACb,OAAO,IAAI,IAAI;QACf,OAAQ,IAAiB,CAAC,KAAK,KAAK,QAAQ;QAC3C,IAAiB,CAAC,KAAK,KAAK,IAAI,CAClC,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,WAAW,CACzB,KAAsB,EACtB,KAAa,EACb,QAAc;IAEd,OAAO,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,KAAK,CAAC,IAAI,QAAQ,CAAC;AACpD,CAAC;AAED;;;;;;;;;;;;;;;;;GAiBG"}
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@gusnips/http",
3
+ "version": "0.1.0",
4
+ "description": "The HTTP envelope an API and its clients agree on: {data, meta} or {error:{code, message, messageKey, params, details}}. Types only, zero dependencies, no framework.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Gustavo Salomé",
8
+ "homepage": "https://github.com/gusnips/frontkit/tree/main/http#readme",
9
+ "bugs": {
10
+ "url": "https://github.com/gusnips/frontkit/issues"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ }
17
+ },
18
+ "types": "./dist/index.d.ts",
19
+ "files": [
20
+ "dist",
21
+ "src",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "sideEffects": false,
26
+ "engines": {
27
+ "node": ">=22"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "keywords": [
33
+ "http",
34
+ "api",
35
+ "envelope",
36
+ "error-codes",
37
+ "typescript"
38
+ ],
39
+ "scripts": {
40
+ "build": "tsc",
41
+ "typecheck": "tsc --noEmit",
42
+ "test": "vitest run --passWithNoTests",
43
+ "test:watch": "vitest",
44
+ "lint": "eslint src",
45
+ "sync:docs": "cp ../LICENSE .",
46
+ "prepublishOnly": "bun run lint && bun run typecheck && bun run test && bun run build && bun run sync:docs",
47
+ "release:patch": "bun pm version patch && bun publish --access public",
48
+ "release:minor": "bun pm version minor && bun publish --access public",
49
+ "release:major": "bun pm version major && bun publish --access public"
50
+ },
51
+ "devDependencies": {
52
+ "typescript": "^5.9.3",
53
+ "vitest": "^4.1.2"
54
+ },
55
+ "repository": {
56
+ "type": "git",
57
+ "url": "git+https://github.com/gusnips/frontkit.git",
58
+ "directory": "http"
59
+ }
60
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { asErrorCode, isApiError } from "./index.ts";
3
+
4
+ const CODES = ["NOT_FOUND", "RATE_LIMIT_EXCEEDED", "INTERNAL_ERROR"] as const;
5
+ type Code = (typeof CODES)[number];
6
+
7
+ describe("asErrorCode", () => {
8
+ it("passes a known code through", () => {
9
+ expect(asErrorCode<Code>(CODES, "NOT_FOUND", "INTERNAL_ERROR")).toBe("NOT_FOUND");
10
+ });
11
+
12
+ // The invariant: a server deployed ahead of the bundle a tab is still running sends a code
13
+ // this build has never heard of. That must degrade, not throw — an exception here would
14
+ // turn a refusal the server explained into a crash the client cannot report.
15
+ it("falls back rather than throwing on a code from a newer server", () => {
16
+ expect(asErrorCode<Code>(CODES, "SOME_CODE_SHIPPED_LAST_TUESDAY", "INTERNAL_ERROR")).toBe(
17
+ "INTERNAL_ERROR",
18
+ );
19
+ });
20
+ });
21
+
22
+ describe("isApiError", () => {
23
+ it("recognises the error half of the envelope", () => {
24
+ expect(isApiError({ error: { code: "NOT_FOUND", message: "no" } })).toBe(true);
25
+ });
26
+
27
+ it("rejects the success half, and anything that is not an object", () => {
28
+ expect(isApiError({ data: { id: 1 } })).toBe(false);
29
+ expect(isApiError(null)).toBe(false);
30
+ expect(isApiError("error")).toBe(false);
31
+ // `{error: null}` is the one that a bare `"error" in body` check gets wrong — a route
32
+ // that serialises a null error field would be read as a refusal with no code.
33
+ expect(isApiError({ error: null })).toBe(false);
34
+ });
35
+ });
package/src/index.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The HTTP envelope an API and its clients agree on.
3
+ *
4
+ * Every route answers one of two shapes — `{data, meta?}` or `{error:{…}}` — so unwrapping
5
+ * and error-shaping belong in one place rather than in every hook. This module is that
6
+ * place, and it is deliberately types plus three tiny functions: the API server produces
7
+ * the envelope, the browser client consumes it, the SDK re-exports it, and an agent reads
8
+ * it. All four compile this file unchanged, which is why it carries no framework and no
9
+ * platform (see `scripts/check-purity.ts`).
10
+ *
11
+ * **The codes are yours, not ours.** Two donor repos had 30 codes and 16 codes respectively,
12
+ * overlapping on nine; the list is an API's vocabulary and belongs to it. Everything here is
13
+ * generic over that union, so a product declares its own codes once and gets the envelope,
14
+ * the status map and the narrowing for free.
15
+ */
16
+
17
+ /** Where a list route puts its counts. Identical in every donor, to the field. */
18
+ export interface PaginationMeta {
19
+ total: number;
20
+ limit: number;
21
+ offset: number;
22
+ hasMore: boolean;
23
+ }
24
+
25
+ /**
26
+ * Success envelope — every 2xx JSON body is exactly this shape.
27
+ *
28
+ * `M` is the meta a route may attach. It defaults to {@link PaginationMeta} because a list
29
+ * route is the common case; a metered API passes its own union instead (one donor carries
30
+ * cache/credit counters there, and its rule — a cache hit is free and visibly so — is the
31
+ * kind of thing that must stay in that product).
32
+ */
33
+ export interface ApiSuccess<T, M = PaginationMeta> {
34
+ data: T;
35
+ meta?: M;
36
+ }
37
+
38
+ /**
39
+ * Error envelope — byte-compatible with a server's `AppError.toJSON()`.
40
+ *
41
+ * The four fields are not decoration, and every donor arrived at the same four:
42
+ *
43
+ * - `code` is what a client switches on. Machine-oriented and stable.
44
+ * - `message` is English, for logs, `curl` output and agents. It is the fallback a client
45
+ * shows when it cannot do better — never the primary copy for a person.
46
+ * - `messageKey` names the SENTENCE, so a client can localize or re-word a refusal without
47
+ * the server knowing any language. The server owns the condition; the client owns the prose.
48
+ * - `params` fills that sentence's blanks, and `details` carries what makes a refusal
49
+ * ACTIONABLE — the `resetAt` on a 429, the plan that lifts a 402. A refusal a caller cannot
50
+ * act on is a dead end, which is the thing the whole product rule exists to prevent.
51
+ */
52
+ export interface ApiError<Code extends string = string> {
53
+ error: {
54
+ code: Code;
55
+ /** English fallback — for logs, curl output and agents. Not shown when `messageKey` resolves. */
56
+ message: string;
57
+ /** Stable key naming the sentence, for a client that localizes or brands its copy. */
58
+ messageKey?: string;
59
+ /** Interpolation values for `messageKey`. */
60
+ params?: Record<string, string | number>;
61
+ details?: unknown;
62
+ };
63
+ }
64
+
65
+ export type ApiResponse<T, Code extends string = string, M = PaginationMeta> =
66
+ ApiSuccess<T, M> | ApiError<Code>;
67
+
68
+ /** True when a parsed body is the error half of the envelope. */
69
+ export function isApiError<Code extends string>(body: unknown): body is ApiError<Code> {
70
+ return (
71
+ typeof body === "object" &&
72
+ body !== null &&
73
+ "error" in body &&
74
+ typeof (body as ApiError).error === "object" &&
75
+ (body as ApiError).error !== null
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Narrow a code off the wire against the product's own list.
81
+ *
82
+ * An unrecognized code means the SERVER IS NEWER than this client — a deploy that landed
83
+ * ahead of the bundle a tab is still running. That is not a parse failure and must not throw;
84
+ * `fallback` is the honest read of "something failed and this build cannot classify it".
85
+ */
86
+ export function asErrorCode<Code extends string>(
87
+ codes: readonly Code[],
88
+ value: string,
89
+ fallback: Code,
90
+ ): Code {
91
+ return codes.find((c) => c === value) ?? fallback;
92
+ }
93
+
94
+ /**
95
+ * A note on the code→status map, which is deliberately NOT a function here.
96
+ *
97
+ * Write it at the call site and let `satisfies` do the work:
98
+ *
99
+ * ```ts
100
+ * export const ERROR_STATUS = {
101
+ * NOT_FOUND: 404,
102
+ * RATE_LIMIT_EXCEEDED: 429,
103
+ * } as const satisfies Record<HttpErrorCode, number>;
104
+ * ```
105
+ *
106
+ * That is one line, and it is the line that matters: adding a code to the union without
107
+ * giving it a status becomes a build error rather than a route answering 500 for a refusal
108
+ * it knew how to explain. A helper wrapping this would add a call and subtract nothing.
109
+ * Keep every such map exhaustive — providerkit learned the same about its error→copy maps,
110
+ * where a widened union fell through to a status code in every locale at once.
111
+ */