rest: narrow type based on status code
vi did:web:vt3e.cat
Fri, 19 Jun 2026 09:00:25 +0100
5 files changed,
98 insertions(+),
51 deletions(-)
M
pkgs/rest/README.md
→
pkgs/rest/README.md
@@ -3,14 +3,25 @@
fully typesafe discord rest client ```ts -import { RESTClient } from "@purrkit/rest"; +import { RESTClient } from "../src"; const client = RESTClient({ token: "..." }); -const response = await client.users.me.get(); -if (response.ok) - console.log(response.data.username); // strongly typed User -else console.error(response.error); // strongly typed DiscordError +const user = await client.users.me.get(); +if (user.ok) { + console.log(user.data.username); // (property) data: UserPIIResponse +} else { + console.error(user.data.message); // (property) data: RatelimitedResponse | ErrorResponse +} + +const guild = await client.guilds("1359315933797285948").channels.get(); +if (guild.status === 200) { + console.log(guild.data); // (property) data: (GuildChannelResponse | PrivateChannelResponse | PrivateGroupChannelResponse | ThreadResponse)[] | null +} else if (guild.status === 429) { + console.log(guild.data.retry_after); // (property) data: RatelimitedResponse +} else if (!guild.ok) { + console.log(guild.data.message); // (property) data: ErrorResponse +} ``` ## copying
D
pkgs/rest/examples/main.ts
@@ -1,8 +0,0 @@
-import { RESTClient } from "../src"; - -const client = RESTClient({ token: "..." }); - -const response = await client.users.me.get(); -if (response.ok) - console.log(response.data.username); // strongly typed User -else console.error(response.data.message); // strongly typed DiscordError
M
pkgs/rest/package.json
→
pkgs/rest/package.json
@@ -1,5 +1,6 @@
{ "name": "@purrkit/rest", + "version": "1.0.0", "files": [ "dist", "README.md",@@ -7,6 +8,16 @@ "LICENSE"
], "type": "module", "module": "src/index.ts", + "types": "./dist/index.d.ts", + "exports": { + ".": "./dist/index.js" + }, + "scripts": { + "build:types": "tsc --emitDeclarationOnly", + "build:js": "bun build ./src/index.ts --outdir ./dist --minify --target bun", + "build": "bun run build:types && bun run build:js", + "prepublishOnly": "bun run build" + }, "dependencies": { "@purrkit/types": "workspace:*" },
M
pkgs/rest/src/types.ts
→
pkgs/rest/src/types.ts
@@ -17,11 +17,9 @@ ) => void
? I : never; -type GetSuccess<Op> = Op extends { response: infer R } ? R : undefined; type GetQuery<Op> = Op extends { parameters: { query: infer Q } } ? Q : never; type GetBody<Op> = Op extends { requestBody: infer B } ? B : never; -// TODO)) @purrkit/types keep hold of the non-200 response types export interface DiscordError { message: string; code?: number;@@ -29,8 +27,51 @@ errors?: Record<string, unknown>;
} export type ApiResponse<TSuccess, TError = DiscordError> = - | { ok: true; data: TSuccess; status: number } - | { ok: false; data: TError; status: number }; + | { ok: true; data: TSuccess; status: number & {} } + | { ok: false; data: TError; status: number & {} }; + +type IsSuccessStatus<K> = K extends number | string + ? `${K}` extends `2${string}` + ? true + : false + : false; + +type Discord2XX = 200 | 201 | 204; +type Discord3XX = 304; +type Discord4XX = 400 | 401 | 403 | 404 | 405 | 410 | 429; +type Discord5XX = 500 | 502 | 503 | 504; + +type GetExplicitKeys<Responses> = { + [K in keyof Responses]: K extends number ? K : K extends `${infer N extends number}` ? N : never; +}[keyof Responses]; + +type MapStatusType<K, ExplicitKeys> = K extends number + ? K + : K extends `${infer N extends number}` + ? N + : K extends "2XX" + ? Exclude<Discord2XX, ExplicitKeys> + : K extends "3XX" + ? Exclude<Discord3XX, ExplicitKeys> + : K extends "4XX" + ? Exclude<Discord4XX, ExplicitKeys> + : K extends "5XX" + ? Exclude<Discord5XX, ExplicitKeys> + : K extends "default" + ? Exclude<Discord4XX | Discord5XX, ExplicitKeys> + : number & {}; + +export type ResolveResponse<Op> = Op extends { responses: infer Responses } + ? keyof Responses extends never + ? ApiResponse<unknown> + : { + [K in keyof Responses]: { + ok: IsSuccessStatus<K>; + status: MapStatusType<K, GetExplicitKeys<Responses>>; + data: Responses[K]; + }; + }[keyof Responses] + : ApiResponse<unknown>; type MapVerb<M> = M extends "post" ? "create" | "post"@@ -41,21 +82,17 @@ ? "delete"
: M; type MethodFunc<Op, Method> = Method extends "get" - ? (options?: { query?: GetQuery<Op> }) => Promise<ApiResponse<GetSuccess<Op>>> + ? (options?: { query?: GetQuery<Op> }) => Promise<ResolveResponse<Op>> : [GetBody<Op>] extends [never] - ? (body?: undefined, options?: { query?: GetQuery<Op> }) => Promise<ApiResponse<GetSuccess<Op>>> - : ( - body: GetBody<Op>, - options?: { query?: GetQuery<Op> }, - ) => Promise<ApiResponse<GetSuccess<Op>>>; + ? (body?: undefined, options?: { query?: GetQuery<Op> }) => Promise<ResolveResponse<Op>> + : (body: GetBody<Op>, options?: { query?: GetQuery<Op> }) => Promise<ResolveResponse<Op>>; type MapOperations<Operations> = { [Method in keyof Operations as MapVerb<Method>]: MethodFunc<Operations[Method], Method>; } & (Operations extends { get: unknown } - ? PromiseLike<ApiResponse<GetSuccess<Operations["get"]>>> + ? PromiseLike<ResolveResponse<Operations["get"]>> : unknown); -// strips '@' from any segment ie @me -> me type MapSegment<S extends string> = S extends `@${infer Rest}` ? Rest : S; type ParamKey = "$param";@@ -69,7 +106,7 @@ : { [K in MapSegment<Head>]: BuildNested<Tail, Operations> }
: MapOperations<Operations>; type TransformTree<T> = T extends (...args: never) => unknown - ? T // w want to keep functions intact + ? T : T extends object ? ParamKey extends keyof T ? ((id: string) => TransformTree<T[ParamKey]>) & OmitTransform<T, ParamKey>
M
pkgs/rest/tsconfig.json
→
pkgs/rest/tsconfig.json
@@ -1,30 +1,26 @@
{ - "compilerOptions": { - // Environment setup & latest features - "lib": ["ESNext"], - "target": "ESNext", - "module": "Preserve", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - "types": ["bun"], + "compilerOptions": { + "outDir": "dist", + "declaration": true, - // Bundler mode - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "types": ["bun"], - // Best practices - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false - } + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } }