Export your first SDK operation, call it from a separate application, and reuse the definition with another client.
Getting started: your first SDK operation
You’ll write two files: an SDK module that exports GET /users/:id, and an application that imports it and owns the client. The local Fetch handle keeps the first run offline. The same API definition will then work with a client pointed at your service.
Best practice
A definition stays independent of the client. Call the operation with the application's client, as in getUser(client, { id: 7 }). There is no third options argument. Calling the operation starts it; await only waits. The application also owns the service address and credentials. Inspect the error-first tuple first. When error is present, report error.code and return from the function before reading the success value.
Step 1 — Install
@defjs/core is ESM and runs on Node.js or Bun. Node.js 22.18+ (or 24+) runs the .ts file directly through type stripping; older 22.x releases need --experimental-strip-types. Keep "type": "module" in package.json. In a browser you still need your bundler and Fetch.
bun add @defjs/coreStep 2 — Define the request
Create src/define-request.ts. This is the SDK author's module: it exports getUser without creating a client. The input struct is the caller's value. builder places that value on the request. Here the whole input is the path object, so alias-free scalar fields fill :id.
import { defineRequest, struct } from "@defjs/core";
const User = struct.object({
id: struct.number(),
name: struct.string(),
});
const NotFound = struct.object({
message: struct.string(),
});
export const getUser = defineRequest({
method: "GET",
path: "/users/:id",
input: struct.object({ id: struct.number() }),
builder: (input) => ({ path: input }),
output: User,
error: NotFound,
});
output is the 2xx body. error is every other status. One struct each. Use struct.or(...) or a discriminated union when those bodies differ.
defineRequest(...) returns an operation. Calling getUser(client, input) starts the request. The builder above is the function that places input; it is not the operation. Where input is declared, builder is required, synchronous, and pure.
Step 3 — Call it locally
Create src/execute-locally.ts beside the definition. This is the consuming application: it imports getUser, creates its own client, and supplies a local Fetch handle. Defjs still validates input, builds the Request, dispatches by status, and parses the body.
import {
createClient,
withEndpoint,
withHTTPHandle,
type FetchHandle,
} from "@defjs/core";
import { getUser } from "./define-request.ts";
const handle: FetchHandle = async (input, init) => {
const request = new Request(input, init);
const id = new URL(request.url).pathname.split("/").at(-1);
if (id === "7") {
return Response.json({ id: 7, name: "Ada" }, { status: 200 });
}
return Response.json({ message: "User not found" }, { status: 404 });
};
const client = createClient(
withEndpoint("https://api.example.test"),
withHTTPHandle(handle),
);
async function main() {
const [error, user, response] = await getUser(
client({ signal: AbortSignal.timeout(5_000) }),
{ id: 7 },
);
if (error?.code === "HTTP_STATUS" && error.status === 404) {
console.log(error.data.message);
return;
}
if (error) {
console.error(error.code);
return;
}
console.log(`Loaded ${user.name} from ${response.status}`);
}
void main();Run it:
bun src/execute-locally.tsLoaded Ada from 200Try a missing user — change the path id to 8 and run again:
User not foundOn success: error is null, user is the 200 Struct output, response is a DecodedResponse<User>. On a declared 404: error.code is 'HTTP_STATUS', error.status is 404, and error.data is typed NotFound. The second tuple item is undefined on failure.
Step 4 — Point at a real API
In src/real-api.ts, import the same getUser and create a client with your service's base URL. Run this version only after replacing the example address with a service that implements GET /v1/users/:id with those bodies.
import { createClient, withEndpoint } from "@defjs/core";
import { getUser } from "./define-request.ts";
const client = createClient(withEndpoint("https://api.example.com/v1"));
async function main() {
const [error, user] = await getUser(client, { id: 7 });
if (error) {
console.error(error.code);
return;
}
console.log(user.name);
}
void main();The API definition stays in the SDK module. Service addresses, credentials, interceptors, and execution lifetime stay with the application. Both clients use the same Defjs endpoint contract.
Publish the definition
Move the definition into an SDK package when more applications need it. Export getUser, include its TypeScript declarations, and declare the compatible Core version. Consumers then import from the package instead of ./define-request.ts; their client configuration remains their own. Follow Build and publish an SDK for the complete package setup.
When the result differs
- Bad input →
REQ_INPUT_INVALID; a failingbuilder→REQ_BUILD_FAILED - A non-2xx status →
HTTP_STATUS, with typederror.datawhenerrorwas declared and its body decodes - A declared JSON body with another media type →
RES_MEDIA_TYPE_INVALID, reported before the body is read - A declared body (
outputfor 2xx,errorotherwise) that won't decode →RES_DECODE_FAILEDorRES_STRUCT_MISMATCH, whatever the status - Fetch fail / cancel / deadline →
NET_UNREACHABLE/NET_ABORTED/NET_TIMEOUT
A deadline is client({ signal: AbortSignal.timeout(ms) }) and classifies as NET_TIMEOUT. A manual abort is NET_ABORTED. There is no client-level default deadline. Derive a client when one call needs its own signal; see Client. Cancellation tells you what the caller saw — not whether a server write committed.