Functional API orchestration primitives for TypeScript applications.
Direttore turns third-party service contracts into an executable API tree. It is the layer between your project and backends you do not own: a place to encapsulate service auth, metadata, request construction, wire response parsing, domain normalization, error mapping, cache keys, and settled-call telemetry. It does not own cache storage, retries, rendering state, or application state.
npm install @direttore/core
Direttore targets Node >=18.17 and runtimes with the standard Fetch API.
import {
byStatus,
byTransportKind,
createApi,
input,
schema,
} from "@direttore/core";
type RequestMeta = {
feature: "profile" | "settings";
};
type AuthPolicy = {
scope: "users:read" | "users:write";
};
type UserWire = {
data: {
id: string;
full_name: string;
};
};
type User = {
id: string;
name: string;
};
const d = createApi<RequestMeta, AuthPolicy>();
export const identity = d.service({
baseUrl: "https://identity.example.com",
auth: {
scope: "users:read",
},
getAuthHeaders: async (ctx) => ({
authorization: `Bearer ${await session.getToken(ctx.auth?.scope)}`,
}),
mapHttpError: byStatus({
401: {
message: "Sign in again.",
code: "SESSION_EXPIRED",
status: 401,
},
404: {
message: "User was not found.",
code: "USER_NOT_FOUND",
status: 404,
},
"5xx": ({ body, response }) => ({
message: "Identity is unavailable.",
code: "IDENTITY_UNAVAILABLE",
status: response.status,
raw: body,
}),
default: ({ body, response }) => ({
message: "Identity request failed.",
status: response.status,
raw: body,
}),
}),
mapTransportError: byTransportKind({
clientOffline: {
message: "You appear to be offline.",
code: "CLIENT_OFFLINE",
},
serviceUnreachable: ({ error }) => ({
message: "Identity is unreachable.",
code: "IDENTITY_UNREACHABLE",
raw: error,
}),
}),
endpoints: {
getUser: d.endpoint({
kind: "query",
input: input(userInputSchema),
path: "/users/:id",
params: ({ id }) => ({ id }),
key: ({ id }) => [id],
response: schema(userWireSchema),
mapResponse: (wire: UserWire): User => ({
id: wire.data.id,
name: wire.data.full_name,
}),
}),
},
});
export const api = d.client().use({
identity,
});
await api.identity.getUser.fetch({ id: "u1" });
api.identity.getUser.cacheKey({ id: "u1" });Use createApi<TMeta, TAuth>() when an application has typed metadata and auth policy:
const d = createApi<RequestMeta, AuthPolicy>();
d.endpoint(config);
d.service(config);
d.client(config);The unbound endpoint(), service(), and client() functions are also exported for
simple or library-level use.
Every endpoint must explicitly declare input:
import {
input,
none,
passthrough,
} from "@direttore/core";
input(userInputSchema); // validated caller input
passthrough<{ id: string }>(); // typed schema-less caller input
none(); // no caller inputNo runtime input guessing is performed. Inputful endpoints are always called as
fetch(input, options). No-input endpoints are called as fetch(options?).
A service is the integration boundary. Put vendor-wide concerns here and let endpoints describe only the route-specific parts:
const identity = d.service({
baseUrl: "https://identity.example.com",
auth: {
scope: "users:read",
},
meta: {
vendor: "identity",
},
getAuthHeaders: async (ctx) => ({
authorization: `Bearer ${await session.getToken(ctx.auth?.scope)}`,
}),
mapHttpError: byStatus({
401: { message: "Sign in again.", code: "SESSION_EXPIRED", status: 401 },
"5xx": ({ response, body }) => ({
message: "Identity is unavailable.",
code: "IDENTITY_UNAVAILABLE",
status: response.status,
raw: body,
}),
}),
observe: (event) => {
console.debug(event.endpointName, event.outcome);
},
endpoints: {},
});Important service fields:
| Field | Purpose |
|---|---|
baseUrl | base URL shared by fetch-backed endpoints |
fetch | optional service-specific Fetch implementation |
headers | headers shared by every endpoint in the service |
requestInit | shared Fetch init defaults |
auth | default auth policy for endpoints in the service |
meta | default metadata for calls in the service |
getAuthHeaders | converts resolved auth/meta/request context into headers |
parseErrorResponse | shared non-2xx response body parser |
mapHttpError | shared non-2xx error mapper |
mapTransportError | shared Fetch rejection mapper |
observe | shared settled-call observer |
Client config supports the same cross-service fields. Runtime resolution is
endpoint first, then service, then client. Per-call meta overrides endpoint, service,
and client metadata.
d.endpoint({
kind: "query",
input: input(userInputSchema),
path: "/users/:id",
params: ({ id }) => ({ id }),
query: ({ expand }) => ({ expand }),
key: ({ id }) => [id],
response: schema(userWireSchema),
mapResponse: toUser,
});Important fields:
| Field | Purpose |
|---|---|
kind | "query" or "mutation"; queries default to GET, mutations to POST |
input | input(schema), passthrough<T>(), or none() |
path | fetch-backed endpoint path or path factory |
resolve | custom resolver; mutually exclusive with path |
params | explicit path placeholder values for :id and {id} |
query | explicit query string mapper |
body | explicit request body mapper |
auth | endpoint-specific auth policy override |
meta | endpoint-specific call metadata default |
key | required for inputful queries; returns cache key parts |
response | schema/parser for decoded transport response |
mapResponse | maps decoded response to app-facing output |
output | optional final output schema/parser |
mapHttpError | maps non-2xx responses to app errors |
mapTransportError | maps Fetch rejections to app errors |
observe | receives a settled success/error event |
Inputful query endpoints require key(input). Runtime cache keys are:
[serviceName, endpointKey, ...key(input)]No-input endpoints use:
[serviceName, endpointKey]Fetch-backed endpoints build requests in this order:
baseUrl.requestInit.GET and HEAD never receive bodies. Plain objects and arrays are JSON serialized and
receive content-type: application/json when no content type is already set. BodyInit
values pass through unchanged.
Middleware has been removed. Use explicit request mappers for request construction and
observe for logging, metrics, tracing, and analytics.
Observers can be registered on client, service, or endpoint:
const api = d.client({
observe: (event) => {
event.input;
event.output;
event.error;
event.serviceName;
event.endpointName;
event.auth;
event.meta;
event.request;
event.response;
event.timings.totalMs;
event.timings.fetchMs;
event.timings.serverTiming;
},
});timings.fetchMs is client-observed Fetch duration. True backend timing can be exposed
by the server through the Server-Timing header, available as
event.timings.serverTiming.
Observer failures are ignored; telemetry never changes endpoint results.
Default error behavior:
| Failure | Rejection |
|---|---|
| non-2xx Fetch response | ApiError with HTTP_ERROR by default |
| Fetch rejection while offline | ApiError with CLIENT_OFFLINE |
| other Fetch rejection | ApiError with SERVICE_UNREACHABLE |
| Fetch abort | original abort error |
| input/response/output validation | ValidationError |
| invalid JSON success response | ResponseParseError |
| custom resolver rejection | original error |
Use parseErrorResponse, mapHttpError, and mapTransportError to normalize service
errors into ApiError. payload is the app-facing place for parsed backend-specific
error bodies, while raw can keep the original value for diagnostics. Direttore also
exports table helpers for the common cases:
const identity = d.service({
mapHttpError: byStatus({
400: ({ body }) => ({
message: "Invalid profile data.",
code: "PROFILE_INVALID",
payload: body,
raw: body,
}),
401: {
message: "Sign in again.",
code: "SESSION_EXPIRED",
status: 401,
},
404: {
message: "Profile was not found.",
code: "PROFILE_NOT_FOUND",
status: 404,
},
"5xx": ({ response, body }) => ({
message: "Identity is unavailable.",
code: "IDENTITY_UNAVAILABLE",
status: response.status,
raw: body,
}),
default: ({ response, body }) => ({
message: "Identity request failed.",
status: response.status,
raw: body,
}),
}),
mapTransportError: byTransportKind({
clientOffline: {
message: "You appear to be offline.",
code: "CLIENT_OFFLINE",
},
serviceUnreachable: ({ error }) => ({
message: "Identity is unreachable.",
code: "IDENTITY_UNREACHABLE",
raw: error,
}),
}),
endpoints: {},
});byStatus() checks exact status codes first, then status classes like "4xx" or
"5xx", then default. byTransportKind() checks clientOffline,
serviceUnreachable, then default. Handler values may be functions or static
normalized error objects.
Any object with a string message is normalized to ApiError. code, errorCode,
status, fields, errors, payload, raw, and response are copied when present.
Use apiError(code, message, init?) when you want literal error codes and payloads to
flow into EndpointError<typeof endpoint>.
Use resolve when the endpoint is not Fetch-backed:
const health = d.endpoint({
kind: "query",
input: none(),
resolve: async () => "ok",
response: schema(stringSchema),
});resolve is mutually exclusive with path. It still participates in input parsing,
response validation, response mapping, output validation, and observation.
Core exports the main functions:
createApiendpointserviceclientnoneinputpassthroughschemabyStatusbyTransportKindbyTransportErrorAnd the primary error classes:
ApiErrorapiErrorisApiErrorHttpResponseErrorResponseParseErrorValidationErrorthat’s all. not much, but it’s honest work.