@nghien-ot/rux
v0.1.2-dev
Published
A declarative, type-safe HTTP client for TypeScript with zero runtime dependencies.
Maintainers
Readme
Rux
[!WARNING] Development preview — This library is still in active development. Breaking changes are expected until a stable 1.x release. Pin dependency versions and check release notes before upgrading.
A declarative, type-safe HTTP client for TypeScript with zero runtime dependencies.
Define your API with defineClient and get typed endpoint methods, runtime validation for bodies and responses, and flexible error handling.
Features
- Zero runtime dependencies — no third-party runtime packages; schemas are plain objects
- Declarative schemas — plain object literals; use
as constwhere you want literal inference - Compile-time inference —
SchemaToType/Inferderive TypeScript types from schemas - Path params — bracket DSL
:name[string],:name[number],:name[boolean]for typedparamsand URL substitution - Runtime validation — request body (when defined),
queryParams(when defined), and every response body - Error modes — per-client or per-call:
result,throw, orfallback(withdefaultValue) - Method-aware types —
bodyonly onPOST,PUT,PATCH - Auth — bearer, basic, and custom header configuration
- Standalone validation —
validate,validateResponse,handleValidationfor non-HTTP use
Installation
bun add @nghien-ot/ruxOr with npm/pnpm:
npm install @nghien-ot/ruxQuick start
import { defineClient } from "@nghien-ot/rux";
const userResponse = {
type: "object",
properties: {
id: "string",
name: "string",
email: "string",
},
} as const;
const api = defineClient({
baseUrl: "https://api.example.com",
errorMode: "result",
endpoints: {
getUser: {
method: "GET",
path: "/users/:id[string]",
response: userResponse,
},
createUser: {
method: "POST",
path: "/users",
response: userResponse,
body: {
type: "object",
properties: { name: "string", email: "string" },
},
},
deleteUser: {
method: "DELETE",
path: "/users/:id[string]",
response: { type: "object", properties: { success: "boolean" } } as const,
},
},
});Use :id[string] (or [number] / [boolean]) so params is typed and segments are substituted in the URL. A plain :id without brackets is not replaced at runtime.
Schema format
| Schema | TypeScript type |
| ----------------------------------------- | ---------------------------------- |
| "string" | string |
| "number" | number |
| "boolean" | boolean |
| "unknown" | unknown |
| { type: "object", properties: { ... } } | Object with required/optional keys |
| { type: "array", items: ... } | T[] |
Optional object fields use { type: "string", optional: true }. Nullable fields use nullable: true.
import type { SchemaToType } from "@nghien-ot/rux";
type User = SchemaToType<typeof userResponse>;Infer usage (schema, client methods, and endpoint definitions)
Infer maps schemas to TypeScript types and can pick a single slice from a client endpoint function or an endpoint definition.
- Schema:
Infer<S>is the same asSchemaToType<S>. - Client method:
Infer<typeof api.getUser, K>whereKis one of"response","path","params","query", or"body"(only for POST/PUT/PATCH). Omit the second argument to get the response type:Infer<typeof api.getUser>. - Endpoint definition: same keys, e.g.
Infer<(typeof endpoints)["getUser"], "response">. - Second argument: pass exactly one key per call (not a union of keys). TypeScript will only accept keys that make sense for the endpoint (e.g. GET/DELETE do not allow
"body").
import type { Infer } from "@nghien-ot/rux";
const userResponse = {
type: "object",
properties: { id: "string", name: "string" },
} as const;
type User = Infer<typeof userResponse>; // { id: string; name: string }
const endpoints = {
getUser: {
method: "GET",
path: "/users/:id[string]",
response: userResponse,
},
} as const;
type GetUserResponse = Infer<(typeof endpoints)["getUser"], "response">;
type GetUserPath = Infer<(typeof endpoints)["getUser"], "path">;
type GetUserParams = Infer<(typeof endpoints)["getUser"], "params">;After defineClient, use typeof api.getUser the same way:
type Id = Infer<typeof api.getUser, "params">["id"];Usage
Result mode (default)
Each call returns RuxResult<T>:
const result = await api.getUser({ params: { id: "1" } });
if (result.ok) {
console.log(result.value);
} else {
console.error(result.error);
}Throw mode
Unwraps success or throws a RuxError object (not instanceof Error):
const user = await api.getUser({
params: { id: "1" },
errorMode: "throw",
});Fallback mode
On failure, returns defaultValue:
const user = await api.getUser({
params: { id: "1" },
errorMode: "fallback",
defaultValue: { id: "", name: "Unknown", email: "" },
});If the client uses errorMode: "fallback", every call must supply defaultValue (or the promise rejects with a clear error).
Client-level error mode
const api = defineClient({
baseUrl: "https://api.example.com",
errorMode: "throw",
endpoints: {
/* ... */
},
});
const user = await api.getUser({ params: { id: "1" } });Path parameters (bracket DSL)
Typed params are derived from :name[string], :name[number], or :name[boolean] in path. Example:
path: "/users/:id[string]/posts/:postId[number]";
// params: { id: string; postId: number }Request body
Only on POST, PUT, PATCH. With a body schema, the payload is typed and validated before fetch.
Query string
query:Record<string, string>(and values that stringify for arrays — see below). Extra keys are allowed whenqueryParamsis defined (they are still appended to the URL).queryParamson the endpoint: describe typed query arguments (required,nullable,type: "array"withitems). Invalid query objects fail validation before the request is sent.
Non-array object values in query are skipped when serializing (no nested JSON in the query string).
Auth configuration
const api = defineClient({
baseUrl: "https://api.example.com",
auth: { type: "bearer", token: "my-jwt-token" },
endpoints: { /* ... */ },
});
// Basic auth
auth: {
type: "basic",
credentials: { username: "admin", password: "secret" },
},
// Custom header
auth: {
type: "custom",
header: { name: "x-api-key", value: "my-key" },
},Default header merge order: content-type, auth, client headers, endpoint headers, per-call headers (later wins).
unwrapOrThrow / unwrapOrDefault
import { unwrapOrThrow, unwrapOrDefault } from "@nghien-ot/rux";
const result = await api.getUser({ params: { id: "1" } });
const user = unwrapOrThrow(result);
const safe = unwrapOrDefault(result, { id: "", name: "?", email: "" });Standalone validation
import { validate, validateResponse, handleValidation } from "@nghien-ot/rux";
const schema = {
type: "object",
properties: { name: "string", age: "number" },
} as const;
validate(schema, { name: "John", age: 30 }); // true
const checked = validateResponse<typeof schema>(schema, data);
// Modes for handleValidation use the string "default" (not "fallback"):
const asResult = handleValidation(schema, data, "result");
const value = handleValidation(schema, data, "throw");
const fallback = handleValidation(schema, data, "default", {
name: "",
age: 0,
});Naming note: the HTTP client uses errorMode: "fallback" and defaultValue. handleValidation uses mode "default" and a fourth argument named fallback.
Error types
interface RuxError {
type: "network" | "validation" | "http";
status?: number;
message: string;
cause?: unknown;
}| type | When |
| ------------ | --------------------------------------------------------------------- |
| network | fetch throws (e.g. DNS, timeout) |
| http | Response status is not 2xx |
| validation | Body/query/response failed schema validation, or response is not JSON |
API reference
defineClient(config)
| Field | Description |
| ----------- | --------------------------------------------------------------------------------------- |
| baseUrl | Base URL for all requests (new URL(path, baseUrl)). Must be valid for the URL parser. |
| errorMode | "result" (default), "throw", or "fallback" |
| headers | Default headers for all endpoints |
| auth | AuthConfig (bearer / basic / custom) |
| endpoints | Map of endpoint definitions |
Endpoint definition
| Field | Description |
| ------------- | --------------------------------------------------------------------------- |
| method | GET | POST | PUT | PATCH | DELETE |
| path | Path starting with / or ""; use :name[string] (etc.) for substitution |
| response | Schema for the JSON response body |
| body | Optional request body schema (POST / PUT / PATCH only) |
| queryParams | Optional typed query schema |
| headers | Per-endpoint headers |
Call options
| Field | Description |
| -------------- | ---------------------------------------------------- |
| params | Path parameters (when the path uses the bracket DSL) |
| query | Query string values |
| body | JSON body (body methods only) |
| headers | Per-call headers |
| errorMode | Override client error mode |
| defaultValue | Required when using errorMode: "fallback" |
Caveats
- Path substitution only applies to
:name[type]segments; plain:idis left as-is. JSON.stringifyforbodyis not wrapped: circular data orBigIntcan cause a rejected promise.- For more edge cases and manual QA commands, see
.qa/MANUAL_QA.md.
Development (contributors)
bun install
bun run build # emit dist/
bun run test # tests/ + ./.qa/live-api.test.ts (live tests skip unless RUN_LIVE_API=1)
bun run qa:manual # build + manual QA against `import "@nghien-ot/rux"` (see .qa/test.ts)Compile-time API assertions live in tests/types.test.ts using Vitest expectTypeOf (checked when you pass --typecheck, as in bun run test). For the mandatory TDD workflow and agent roles, see TDD.md and AGENTS.md.
Optional live smoke tests (network): set RUN_LIVE_API=1 and run bun run test (includes .qa/live-api.test.ts; live cases skip unless the env var is set). See MANUAL_QA.md.
License
MIT
