# Getting started: your first SDK operation [#getting-started-one-http-request]

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 [#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 [#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.

```sh
bun add @defjs/core
```

## Step 2 — Define the request [#step-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`.

```ts define-request.ts
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 [#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.

```ts execute-locally.ts
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:

```sh
bun src/execute-locally.ts
```

```txt
Loaded Ada from 200
```

Try a missing user — change the path id to `8` and run again:

```txt
User not found
```

On 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 [#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.

```ts
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 [#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](/recipes/publish-http-sdk) for the complete package setup.

## When the result differs [#when-the-result-differs]

- Bad input → `REQ_INPUT_INVALID`; a failing `builder` → `REQ_BUILD_FAILED`
- A non-2xx status → `HTTP_STATUS`, with typed `error.data` when `error` was 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 (`output` for 2xx, `error` otherwise) that won't decode → `RES_DECODE_FAILED` or `RES_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](/docs/client). Cancellation tells you what the caller saw — not whether a server write committed.

## Next recipes [#next-recipes]

- [Build and publish an SDK](/recipes/publish-http-sdk)
- [GET with a declared 404](/recipes/get-declared-404)
- [POST JSON](/recipes/post-json)
- [Cancel an HTTP call](/recipes/cancel-http)
- [Consume an SSE stream](/recipes/consume-sse)
- [Open a WebSocket session](/recipes/websocket-session)
- [Test with a local Fetch handle](/recipes/test-with-handle)
