- Roc 86.4%
- TypeScript 11%
- Just 2.6%
End-to-end nominal typing for primitives:
- Schema gains SNamed Str Schema. At use-sites it renders as `$ref`; OpenApi.emit
now produces a `components/schemas` section by walking every spec's response
and body schema and deduping the SNamed nodes by name.
- PokemonApi declares opaque newtypes around primitives:
PokemonName := Str implements [Eq, Encoding, Decoding, Inspect]
PokemonId := U64 implements [Eq, Encoding, Decoding, Inspect]
with `pokeName` / `pokeId` builders (and `pokeNameStr` / `pokeIdNum`
unwrappers for cross-module use, since opaques only destructure in the
defining module). Pokemon and NewPokemon use them; JSON serialization is
transparent via derived Encoding/Decoding.
- Domain schemas are wrapped in `named("...")`, so OpenAPI emits Pokemon,
NewPokemon, WhoAmIResp, PokemonName, PokemonId once in components/schemas
and uses `$ref` everywhere they appear.
- PokemonServer wraps captures with the builders; PokemonClient's body
wrappers (submitPokemon!, updatePokemon!) accept raw Str and wrap
internally so callers don't need to know about the opaque type. client.roc
uses unwrap helpers to print branded fields.
- integration/ts-client post-processes the hey-api output: any `export type
Foo = string;` or `... = number;` is rewritten to a branded intersection
`Foo = string & { readonly __brand: 'Foo' }`. PokemonName and PokemonId
are branded automatically.
- brand-check.ts asserts brands are truly nominal via `@ts-expect-error`
directives — `tsc --noEmit` only succeeds if plain string is rejected
where PokemonName is expected (and same for PokemonId).
- justfile test-ts-client now also runs `bunx tsc --noEmit` so the brand
check is part of the integration test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|---|---|---|
| .zed | ||
| integration/ts-client | ||
| roc-servant | ||
| .envrc | ||
| .gitignore | ||
| devbox.json | ||
| devbox.lock | ||
| justfile | ||
| README.md | ||
roc-servant
A typed HTTP library for Roc, in the spirit of Haskell's Servant and Scala's tapir.
Endpoint values are the single source of truth. From them you derive:
- the routing for a
basic-webserverapp - a typed client for
basic-cli(or any HTTP transport) - an OpenAPI 3.0 document
- a compile-time guarantee that the server implements every endpoint with the right shapes
Quick look
Shared API contract (PokemonApi.roc):
import srv.Endpoint exposing [endpoint, seg, capture, ...]
# No type annotation — Roc infers the body/response types from the
# handler signatures via the routedHandlers wiring below.
getPokemonByName = endpoint |> seg(Pokemon) |> capture(Name) |> get |> responds
createPokemon = endpoint |> seg(Pokemon) |> post |> withBody |> responds
# The contract: any server must satisfy the shape this function expects.
routedHandlers = |impl| [
serves(getPokemonByName, impl.getPokemonByName),
serves(createPokemon, impl.createPokemon),
# ...
]
Server implementation (PokemonServer.roc):
import PokemonApi exposing [routedHandlers]
handlers = {
getPokemonByName: |Bind Name name Nil|
Ok({ name, id: 25, types: ["electric"] }),
createPokemon: |WithBody new Nil|
Ok({ name: new.name, id: 999, types: new.types }),
# ... omit one of these → compile error.
}
allHandlers = routedHandlers(handlers)
Typed client (in the same PokemonApi.roc):
fetchPokemonByName! : Str, Transport => Result Pokemon ClientErr
fetchPokemonByName! = |name, transport!|
Client.call!(getPokemonByName, Bind(Name, name, Nil), transport!)
Project layout
roc-servant/
├── package/ # the library
│ ├── main.roc # package manifest
│ ├── Endpoint.roc # builder, combinators, Handler/HandlerErr types
│ ├── Router.roc # match + erase + dispatch; findConflicts; URI helpers
│ ├── Client.roc # typed call! over a user-supplied Transport
│ └── OpenApi.roc # OpenAPI 3.0 emitter
└── examples/pokemon/
├── PokemonApi.roc # shared: endpoints + client wrappers + allSpecs
├── PokemonServer.roc # impl: handlers record, allHandlers, integration tests
├── main.roc # basic-webserver app
└── client.roc # basic-cli demo using the typed client
Try it
devbox shell
just build # → dist/pokemon-server, dist/pokemon-client
just test # 47 tests
just server & # listens on :8000
just client # hits the server using the typed wrappers
The server also exposes /openapi.json — emitted from the same endpoint
values the router uses.
Editor setup (Zed)
The Roc language server lives at .devbox/nix/profile/default/bin/roc_language_server
once devbox install has run. .zed/settings.json points the roc LSP at
that path. If you launch Zed from devbox shell (zed .), it picks the
binary up via PATH; otherwise the explicit setting handles it.
Combinator reference
endpoint start
seg(Tag) literal path segment /pokemon
capture(Key) Str capture /{name}
captureU64(Key) U64 capture, validated /{id}
queryStr(Key) required ?key=str
queryU64(Key) required ?key=u64
optQueryStr(Key) optional ?key=str Result Str [Missing]
optQueryU64(Key) optional ?key=u64 Result U64 [Missing]
queryFlag(Key) presence-only ?debug Bool
queryList(Key) repeated ?tag=a&tag=b List Str
header(Key) required header PascalCase → kebab-case
optHeader(Key) optional header Result Str [Missing]
withBody typed JSON body body type from annotation
optBody optional JSON body Result T [Missing]
get/post/put/delete/patch/head/options/trace/connect
responds close the chain (output type pinned by annotation)
handle(ep, fn) pair endpoint + impl
Type aliases Get p i o, Post p i o, ... are provided for users who want
to annotate (e.g. for documentation or when inference doesn't suffice). The
Pokemon example shows the no-annotation style is enough in practice.
# Optional explicit annotation:
getPokemonByName : Get _ _ Pokemon
getPokemonByName = ...
Handlers return Result o HandlerErr so they can short-circuit:
HandlerErr : [
NotFound, Forbidden, Unauthorized,
BadRequest Str, ServerError Str, Status U16 Str,
]
Compile-time guarantees
- Method state machine —
endpoint |> get |> getis a type error. - Path captures flow into the handler —
Bind Name ...on the handler side must matchcapture(Name)on the spec side. - Response type pinned by annotation — the
oinEndpoint _ _ _ Pokemonis what the handler must return. - Server completeness —
routedHandlersenumerates every endpoint; the server'shandlersrecord's shape is inferred from it. Omit a handler → compile error. - Client args derived from spec — the typed client wrappers take exactly the captures/query/headers/body the spec demands.
Known gaps (worth doing next)
- CORS preflight — needs
headersonPreResp; deferred. - OpenAPI body schemas are stub
{}— request/response body types need a separateSchema adescription (the RocEncodingability doesn't expose enough structure to derive a JSON schema). - Trie / prefix-tree router — current
routeis linear scan. Fine for tens of endpoints. - Structured client error bodies —
ClientErr.Http U16 Stris the raw body; clients can't decode typed error objects. - Middleware — no auth/logging/rate-limit wrapping primitives yet.