An opinionated architectural choice — never-rest puts Result-based railway-oriented programming at the API boundary. Whether either side uses railway style internally is up to that team and does not matter to the contract. The assumption is that at least one side wants it; otherwise there is no reason to reach for this.
On top of that choice: a ContractDef where handlers return Result instead of throwing. serve projects that contract onto HTTP. ./local runs the same contract in-process — module to module, or behind a host that already carries the operation as a string (NDJSON, MCP stdio, agent tool calls). Errors carry their cause chain across boundaries. Disclosure is graded by caller trust — not blanket obfuscation.
Package: @eddy-works/never-rest · Licence: Apache-2.0 · Peer: neverthrow · Runtime deps: none (validation via Standard Schema)
npm i @eddy-works/never-rest neverthrowpnpm add @eddy-works/never-rest neverthrowneverthrow is a peer dependency — it's the Result / ResultAsync implementation never-rest builds on.
| Module | Key exports |
|---|---|
@eddy-works/never-rest |
RailError, railError, chain, flatten, formatChain, statusFor, toDeclaredResponse, HOST_STATUSES, disclose, respond |
@eddy-works/never-rest/contract |
RouteDef, ContractDef, ClientArgsOf, HandlerArgsOf, OutputOf, ErrorOf, ClientErrorOf, ServerErrorOf, parseRouteSources, parseOutput, compileContract, isContractPath, compilePath, matchPath, normalizePath, assertHandlersComplete, ContractConfigurationError |
@eddy-works/never-rest/server |
serve, Handler, Handlers, ServeHandler, compileRoutes, matchRoute, assertProtocolResponse |
@eddy-works/never-rest/client |
createClient, Client, ClientOptions, buildRequest |
@eddy-works/never-rest/node |
toNodeHandler, FetchHandler, NodeHttpHandler |
@eddy-works/never-rest/local |
createLocalClient, createDispatcher |
@eddy-works/never-rest/testing |
createTestClient, assertProtocolResponse, checkTransportStability, checkContractOutputs |
@eddy-works/never-rest/openapi |
toOpenAPI, OpenApiExportError |
@eddy-works/never-rest/query |
createQueryOptions, createMutationOptions, isRetryable |
Most REST libraries assume handlers throw. Middleware intercepts exceptions, typed errors get lost at the boundary, and clients branch on tuples or catch blocks instead of composing with andThen. Contract DSLs (initContract(), chained builders) inflate TypeScript instantiation cost — @ts-rest/core measures ~5,984 instantiations per route on a 20-route fixture. oRPC types errors on the wire but its server model is throw-based; its non-throwing safe() client does not compose with map / andThen / match. never-rest is contract-first with plain object literals, Result/ResultAsync end to end, and a published per-route type budget enforced in CI.
When handlers return Result, auth, side effects, and after-effects are just functions in the chain — not a separate interceptor stack:
getInvoice: ({ params, request }) =>
requireAuth(request) // gate
.andThen((session) => requireRole(session, 'billing'))
.andTee((session) => metrics.increment('invoice.auth_ok')) // side effect
.andThen((session) => loadInvoiceFor(session, params.id))
.andTee((invoice) => audit.read('invoice', invoice.id)), // after-effect (best-effort)If auth fails, the domain call never runs. Tee effects observe without inventing new failure modes; use andThen when a follow-up must succeed. Full catalogue (router, recover, fan-out, lift, …) with neverthrow and ROP links: docs/railway-patterns.md. Thesis: docs/concepts.md — No middleware.
Handlers return a neverthrow Result — never throw. Compose with map / andThen on the server; the client is the same ResultAsync shape.
import { ok, err, type Result } from 'neverthrow';
import { z } from 'zod';
import { railError, type RailError } from '@eddy-works/never-rest';
import type { ContractDef } from '@eddy-works/never-rest/contract';
import { serve, type Handlers } from '@eddy-works/never-rest/server';
import { createClient } from '@eddy-works/never-rest/client';
const userSchema = z.object({ id: z.string(), name: z.string() });
type User = z.infer<typeof userSchema>;
// Plumbing — declare routes, schemas, and status map.
const contract = {
getUser: {
method: 'GET',
path: '/users/:id',
params: z.object({ id: z.string() }),
output: userSchema,
errors: { not_found: 404 },
},
createUser: {
method: 'POST',
path: '/users',
body: z.object({ name: z.string().min(1) }),
output: userSchema,
success: 201,
errors: { conflict: 409 },
},
} as const satisfies ContractDef;
// Business logic — compose with `map` / `andThen` the same way as the client.
const users = new Map<string, User>([['ada', { id: 'ada', name: 'Ada' }]]);
function findUser(id: string): Result<User, RailError<'not_found'>> {
const user = users.get(id);
if (user === undefined) {
return err(railError('not_found', `User ${id} not found`));
}
return ok(user);
}
function reserveId(name: string): Result<string, RailError<'conflict'>> {
const id = name.toLowerCase();
if (users.has(id)) {
return err(railError('conflict', `User ${id} already exists`));
}
return ok(id);
}
const handlers: Handlers<typeof contract, undefined> = {
getUser: ({ params }): Result<User, RailError<'not_found'>> =>
findUser(params.id).map((user) => ({ ...user, name: user.name.trim() })),
createUser: ({ body }): Result<User, RailError<'conflict'>> =>
reserveId(body.name).map((id) => {
const user = { id, name: body.name };
users.set(id, user);
return user;
}),
};
// Plumbing — mount the contract; disclosure grades what callers see.
export default serve(contract, handlers, {
origin: 'users-api',
disclosure: (req) =>
req.headers.get('x-internal') === '1' ? 'full' : 'public',
});
const client = createClient(contract, { baseUrl: 'https://api.example.com' });
await client
.getUser({ params: { id: 'ada' } })
.andThen((user) => client.createUser({ body: { name: `${user.name} Jr` } }))
.match(
(user) => console.log(user.id),
(error) => console.error(error.code), // not_found | conflict | validation_error | internal | unavailable
);Bring any Standard Schema validator (Zod 4, Valibot, ArkType). Use as const satisfies ContractDef on every contract — without as const, errors widens and domain codes stop being literal. serve returns a callable fetch handler (always answers, including route_not_found) plus cooperative handle() on Workers, Deno, Bun, Node 18+, SvelteKit, Next. For classic Node/http or Express, use toNodeHandler from @eddy-works/never-rest/node.
The same contract also runs without HTTP. Handlers that omit request are LocalHandlers — assignable to serve as well:
import { createLocalClient, createDispatcher } from '@eddy-works/never-rest/local';
const localHandlers = {
getUser: ({ params }) => findUser(params.id),
createUser: ({ body }) =>
reserveId(body.name).map((id) => {
const user = { id, name: body.name };
users.set(id, user);
return user;
}),
};
const localUsers = createLocalClient(contract, localHandlers, {
origin: 'users-api',
});
await localUsers.getUser({ params: { id: 'ada' } });
await createDispatcher(contract, localHandlers, { origin: 'users-api' }).dispatch(
'getUser',
{ params: { id: 'ada' } },
);createLocalClient is one typed method per operation. createDispatcher is the same machinery addressed by operation name, for a socket, MCP stdio, or tool-call host that already has the string. Both validate declared input and output; neither constructs a Request or Response. Disclosure defaults to full. Route errors status maps are ignored. See docs/api.md — local.
Mini projects share one contract and mount it on different runtimes — see examples/README.md:
| Example | Runtime |
|---|---|
| express | Express via ./node |
| hono | Hono |
| next-app-router | Next.js App Router |
| sveltekit | SvelteKit |
| cloudflare-workers | Cloudflare Workers |
| gateway | Cause chains + disclosure |
| validators | Zod / Valibot / ArkType |
| files-and-streams | Sibling multipart + SSE |
pnpm build
pnpm --filter @never-rest-examples/express startnever-rest optimises for measured TypeScript instantiations per route, enforced in CI via @ark/attest. On synthetic 1–40 route fixtures against real src types (TypeScript 5.9.3), combined contract + client marginal slope is ~584 instantiations per route. Published budget: 1,800 per route. Research anchor for @ts-rest/core's c.router() DSL: ~5,984 per route — roughly 10× never-rest.
Methodology, reproduction, and slope breakdown: docs/performance.md.
| Alternative | Prefer it when |
|---|---|
| ts-rest | You want the initContract() builder, existing ecosystem adapters, or OpenAPI generation today. ts-rest is mature for contract-first REST with Zod; its DSL costs substantially more per route in instantiation benchmarks. Last stable release noted in project research: 2025-03-04. |
| oRPC | You want RPC-style procedures, streaming, or framework integrations oRPC already ships. Server handlers use throw errors.NOT_FOUND(); typed errors do not compose as Result. The safe() client returns a tuple, not a composable ResultAsync. |
| Throwing handlers + middleware | Your team already standardises on exception middleware, you do not need cross-service cause chains, and graded disclosure is unnecessary. |
Browsable site: project-eddy.github.io/never-rest.
| Doc | Topic |
|---|---|
| docs/concepts.md | Railway at the boundary, HTTP and local transports, no middleware, errors as data, trust circles |
| docs/railway-patterns.md | Full railway/neverthrow pattern catalogue + white-label tenant kitchen sink |
| docs/advanced-usage.md | Policy without middleware — capabilities, composers, host wraps, agents |
| docs/api.md | Every public export, signature, example — including ./local |
| docs/examples.md | Express, Next, SvelteKit, Hono, Workers, gateway, files-and-streams |
| docs/files-and-streams.md | JSON on the railway; multipart and SSE on the host |
| docs/errors-as-intelligence.md | nextStep, origin, retryable, gateway chains |
| docs/comparison.md | vs ts-rest, oRPC, tRPC, and Hono RPC |
| docs/migrating.md | From ts-rest, oRPC, throwing handlers |
| docs/performance.md | Type instantiation budget (~584/route, CI gate) |
Agent lookup index: skills/never-rest/SKILL.md.
Gherkin scenarios in specs/ — extract with pnpm specs:extract. Tests map one-to-one to scenario titles:
| Spec | Tests |
|---|---|
| specs/status-mapping.spec.md | src/status.test.ts, src/respond.test.ts, src/server/serve.test.ts |
| specs/graded-disclosure.spec.md | src/disclose.test.ts, src/respond.test.ts, src/server/serve.test.ts |
| specs/cause-chaining.spec.md | src/error.test.ts, src/server/serve.test.ts |
| specs/client-results.spec.md | src/client/create.test.ts |
| specs/server-output-validation.spec.md | src/server/serve.test.ts |
| specs/contract-compilation.spec.md | src/contract/compile.test.ts, src/contract/path.test.ts, src/server/serve.test.ts |
| specs/wire-serialization.spec.md | src/client/create.test.ts, src/client/request.ts paths |
| specs/input-sources.spec.md | src/contract/compile.test.ts, src/contract/parse.test.ts |
| specs/openapi-export.spec.md | src/openapi/to-openapi.test.ts |
| specs/local-dispatch.spec.md | src/local/dispatch.test.ts |
| specs/railway-boundary.spec.md | src/railway/ |
See specs/README.md for extraction and layout.