Skip to content

API ReferencedefineGroup

defineGroup

Creates a typed endpoint group with a shared URL prefix and endpoint definitions.

5 min readboth modes

Import

ts
import { defineGroup } from "@noverax/courier/define"

Signature

ts
declare function defineGroup<TEndpoints extends OwnEndpoints>(
  config: DefineGroupConfig<TEndpoints>
): DefinedGroup<TEndpoints>

declare function defineGroup<TGroups extends OwnGroups, TEndpoints extends OwnEndpoints>(
  config: DefineNestingGroupConfig<TEndpoints, TGroups>
): DefinedNestingGroup<TEndpoints, TGroups>

Parameters

DefineGroupConfig

ts
type DefineGroupConfig<E> = GroupOptions & {
  endpoints: E
}

type DefineNestingGroupConfig<E, G> = GroupOptions & {
  endpoints?: E
  groups: G
}

type GroupOptions = {
  prefix?: string
  pathOptions?: (request?: Request) => Record<string, unknown> | Promise<Record<string, unknown>>
  expose?: boolean
  fetch?: SourceFetch
  webSocket?: SourceWebSocket
  dispatcher?: SourceDispatcher
  proxy?: string // Bun
  tls?: object // Bun
  client?: object // Deno
}
  • prefix — URL path segment prepended to all endpoint URLs in this group.
  • pathOptions — async function that returns dynamic key-value pairs (headers, cookies) to inject per request.
  • expose — whether the browser may call the group through the proxy; it overrides the source's, and a group inside another overrides its outer group's.
  • fetch, webSocket, dispatcher — how the group's calls, streams and channels reach their server, over the source's: an undici Agent holding a client certificate for this group alone, a ProxyAgent. A group inside another takes each from the nearest group that sets it, and a call's .with() sets its own over both. Bun's proxy and tls and Deno's client are taken the same way. One that cannot send — a fetch or a webSocket that is not a function, a dispatcher with no dispatch method — throws ConfigurationError("SOURCE_TRANSPORT_INVALID"). See One group.
  • groups — groups inside this one, created with defineGroup, to any depth: see Groups inside groups. A group made only of groups needs no endpoints.
  • endpoints — record of typed endpoint functions. Any name but options, which holds the group's own settings, and raw or with, which every client answers with its own .raw and .with(); an endpoint of one of those names is a type error and throws ConfigurationError("RESERVED_ENDPOINT_NAME"). The same holds for the endpoints given to defineApi, whose groups cannot be named raw or with either. A path called options or raw is fine: name the endpoint getOptions or getRaw. See Reserved names.

Basic Usage

api/users.ts
import { courier, defineGroup } from "@noverax/courier/define"

const { endpoint } = courier()

type User = { id: string; name: string; email: string }

export const users = defineGroup({
  prefix: "users",
  endpoints: {
    getAll: endpoint.get("").response<User[]>(),
    getById: endpoint.get(":id").variables<{ id: string }>().response<User>(),
    create: endpoint.post("").variables<{ name: string; email: string }>().response<User>(),
    update: endpoint.put(":id").variables<{ id: string; name: string }>().response<User>(),
    remove: endpoint.del(":id").variables<{ id: string }>().response<null>(),
  },
})

Prefix

The prefix is prepended to all endpoint URLs within the group:

ts
const products = defineGroup({
  prefix: "api/v2/products",
  endpoints: {
    list: endpoint.get("").response<Product[]>(),
    // → GET {baseURL}/api/v2/products

    getById: endpoint.get(":id").variables<{ id: string }>().response<Product>(),
    // → GET {baseURL}/api/v2/products/:id
  },
})

Dynamic Path Options

pathOptions returns key-value pairs for URL parameter substitution:

ts
const tenantUsers = defineGroup({
  prefix: ":tenantId/users",
  pathOptions: async (req) => {
    const tenantId = req?.headers.get("x-tenant-id") || "default"
    return { tenantId }
  },
  endpoints: {
    list: endpoint.get("").response<User[]>(),
    // When tenantId = "acme": GET {baseURL}/acme/users
  },
})

Path resolution

Path options from defineApi() are resolved first, then group-level pathOptions are merged on top. Group options take precedence for overlapping keys. A caller's variables never replace a key pathOptions returns.

Groups inside groups

A group may hold groups, as a source does, and those groups may hold groups of their own. A client reaches one by its names in turn, and its address joins every group's prefix in order:

ts
const billing = defineGroup({
  prefix: "billing",
  endpoints: {
    summary: endpoint.get("summary").response<{ total: number }>(),
  },
  groups: {
    invoices: defineGroup({
      prefix: "invoices",
      endpoints: {
        list: endpoint.get("").response<Invoice[]>(),
        byId: endpoint.get(":id").variables<{ id: string }>().response<Invoice>(),
      },
    }),
  },
})

const api = defineApi({ baseURL: "https://api.example.com", groups: { billing } })
const client = createCaller(api)

await client.billing.summary() // GET https://api.example.com/billing/summary
await client.billing.invoices.list() // GET https://api.example.com/billing/invoices
  • A group inside another is named by its path, its names joined with dots: billing.invoices. The path is what the proxy carries in _g, what Studio, mocks and query keys name the group by — mock("billing.invoices", "list") — and what a boundary lists an endpoint under: "billing.invoices.list". A name with a dot in it would read as two, so a group or an endpoint named with one throws ConfigurationError("DOTTED_NAME").
  • Each group's pathOptions is resolved in turn, from the source's down, a lower group's values replacing a higher one's. expose is decided by the nearest group that sets it.
  • A group may hold an endpoint and a group of the same name: the client's member is both callable, as the endpoint, and readable, as the group.

Describing an Endpoint

Each endpoint is built from one of the heads courier() returns — endpoint, stream or channel — and addressed with a verb, or with .via() when its address is worked out from the variables:

ts
const { endpoint } = courier()

endpoint.get("").response<User[]>()
// No variables — the caller passes nothing

endpoint.get(":id").variables<{ id: string }>().response<User>()
// Variables required — caller must pass { id: string }

Streams and Channels

A stream delivers server-sent events and a channel is a WebSocket subscription. Each is either served by the process that defines it, with .implement(), or addressed where another process serves it:

Methodstreamchannel
.variables(schema?)✓✓What the caller subscribes with
.response(schema?)✓✓What each event or message carries
.send(schema?)✓What the caller may send; without it the channel only pushes
.topic(key)✓What subscribers share a topic by, when not all of their variables
.get(path?, options?)✓Where another process serves the stream
.connect(path?, options?)✓Where another process serves the channel
.via(probe, options?)✓✓The address worked out from the variables
.implement(handler, options?)✓✓Serve it here: a stream's generator of { data } events; a channel's generator of messages, or its onJoin / onMessage / onLeave handlers once it takes .send()

.use(), .headers(), .timeout(), .retry() and .meta() refine a stream or a channel as they do an endpoint. Streams & Channels shows each one served and consumed.

Endpoint Types

An endpoint is an inert descriptor, not a callable. Three types name one:

ts
import type {
  ChannelEndpoint,  // ChannelEndpoint<Response, Variables, Send>
  HttpEndpoint,     // HttpEndpoint<Response, Variables, Kind extends "query" | "mutation">
  StreamEndpoint,   // StreamEndpoint<Response, Variables>
} from "@noverax/courier/define"

Read the types back off an endpoint with the inference helpers, which is how a caller knows what a method takes and returns:

ts
import { courier } from "@noverax/courier/define"
import type { InferResponse, InferVariables } from "@noverax/courier/define"

const { endpoint } = courier()

const getById = endpoint.get(":id").variables<{ id: string }>().response<User>()

type Response = InferResponse<typeof getById>   // User
type Variables = InferVariables<typeof getById> // { id: string }