No description
  • Roc 86.4%
  • TypeScript 11%
  • Just 2.6%
Find a file
Viktor Hedefalk e766fcf9f6
Branded newtypes: Roc opaque -> OpenAPI named schemas -> TS branded types
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>
2026-05-20 22:53:17 +02:00
.zed Initial commit: roc-servant — a Servant-style typed HTTP library for Roc 2026-05-20 13:19:56 +02:00
integration/ts-client Branded newtypes: Roc opaque -> OpenAPI named schemas -> TS branded types 2026-05-20 22:53:17 +02:00
roc-servant Branded newtypes: Roc opaque -> OpenAPI named schemas -> TS branded types 2026-05-20 22:53:17 +02:00
.envrc Initial commit: roc-servant — a Servant-style typed HTTP library for Roc 2026-05-20 13:19:56 +02:00
.gitignore Add justfile build setup; outputs to /dist instead of next to source 2026-05-20 13:21:13 +02:00
devbox.json Initial commit: roc-servant — a Servant-style typed HTTP library for Roc 2026-05-20 13:19:56 +02:00
devbox.lock Initial commit: roc-servant — a Servant-style typed HTTP library for Roc 2026-05-20 13:19:56 +02:00
justfile Branded newtypes: Roc opaque -> OpenAPI named schemas -> TS branded types 2026-05-20 22:53:17 +02:00
README.md README: update to reflect annotation-free endpoint style 2026-05-20 14:51:46 +02:00

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-webserver app
  • 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 machineendpoint |> get |> get is a type error.
  • Path captures flow into the handlerBind Name ... on the handler side must match capture(Name) on the spec side.
  • Response type pinned by annotation — the o in Endpoint _ _ _ Pokemon is what the handler must return.
  • Server completenessroutedHandlers enumerates every endpoint; the server's handlers record'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 headers on PreResp; deferred.
  • OpenAPI body schemas are stub {} — request/response body types need a separate Schema a description (the Roc Encoding ability doesn't expose enough structure to derive a JSON schema).
  • Trie / prefix-tree router — current route is linear scan. Fine for tens of endpoints.
  • Structured client error bodiesClientErr.Http U16 Str is the raw body; clients can't decode typed error objects.
  • Middleware — no auth/logging/rate-limit wrapping primitives yet.