Fable.Giraffe is a port of the Giraffe F# web framework to Fable. Write your web application once in F# and run it on three runtimes:
| Target | Runtime | Server |
|---|---|---|
| Python | ASGI | uvicorn (Starlette) |
| JavaScript | Node.js | built-in node:http, or mounted as connect/express middleware |
| Erlang/BEAM | OTP | Cowboy |
Fable.Giraffe's major version tracks the Fable compiler it targets: the 5.x line is built with and requires Fable 5. The major is therefore not a SemVer signal — a breaking change can land in a minor release, and will be listed under Breaking changes in CHANGELOG.md. Read it before upgrading.
Beyond the Giraffe handler API, it ships an opt-in endpoint layer that generates an OpenAPI 3.1 document and serves interactive docs, typed JSON with FastAPI-style validation errors, and remoting — all shared across the three targets.
There is one NuGet package per target — add the one for the runtime you are compiling to (all three share the same handler API):
dotnet add package Fable.Giraffe.Python # Python / ASGI
dotnet add package Fable.Giraffe.Js # JavaScript / Node.js
dotnet add package Fable.Giraffe.Beam # Erlang / BEAMYou also need the Fable compiler (dotnet tool install fable) and the target's
runtime dependencies — see Prerequisites.
The handler pipeline is identical on every target:
let webApp =
choose [
route "/ping" >=> text "pong"
route "/json" >=> json {| name = "Dag"; age = 53 |}
]Only how you start the host differs:
// Python — returns an ASGI app for uvicorn
let app =
WebHostBuilder()
.Configure(fun app -> app.UseGiraffe(webApp))
.Build()
// JavaScript — starts a node:http server
WebHostBuilder()
.Configure(fun app -> app.UseGiraffe(webApp))
.Run(8080)
// Erlang/BEAM — starts Cowboy
let start () =
WebHostBuilder()
.Configure(fun app -> app.UseGiraffe(webApp))
.Build(8080)JSON goes through Fable.TypedJson,
which derives a type's decoder, encoder and JSON Schema from a single walk.
That single walk is the point: the schema a generated OpenAPI document publishes
cannot disagree with what the serializer actually writes. In the ASP.NET world
these come from two separate mechanisms — Swashbuckle and System.Text.Json —
and can drift.
Records serialize to camelCase on every target, and unions to a tagged
{"type": "caseName", ...}:
type Order = { Customer: string; LineTotal: decimal }
json { Customer = "Ada"; LineTotal = 12.34m }
// {"customer":"Ada","lineTotal":"12.34"}Field names are identical across targets; exact bytes are not, and cannot be.
Python's json.dumps adds ", " spacing, and Erlang maps have no insertion
order, so BEAM emits keys in term order. Compare parsed JSON, not strings.
DateTime crosses the wire as ISO-8601 UTC, Guid as a canonical uuid, and
decimal as a string — a decimal exists precisely because binary floating
point cannot represent the value, so emitting it as a JSON number would throw
away the guarantee the type was chosen for. Pydantic does the same.
validateJson<'T> answers 422 with a per-field error list instead of
throwing, the way FastAPI does:
POST [ route "/greet" (validateJson<Model> (fun m -> text $"Hello, {m.Name}")) ]$ curl -X POST localhost:8080/greet -d '{"name":"Ada","age":"nope"}'
{"detail":[{"loc":"age","msg":"cannot parse 'nope' as int"}]}Nested failures report a path (address.city, members[1].city). bindJson
still throws — validateJson is additive, so you opt in per route.
Upstream Giraffe generates OpenAPI through
Giraffe.OpenApi, which
requires Giraffe.EndpointRouting and ASP.NET's document pipeline. Neither is
available under Fable, and the reason is structural: HttpHandler is a bare
closure, so route "/ping" erases "/ping" and a composed application carries
no description of itself.
Fable.Giraffe solves it the way Giraffe and FastAPI both do — a declaration-time
route table. Fable.Giraffe.Endpoints is an opt-in layer that lowers onto
the ordinary route / routef / subRoute / choose combinators, so there is
still exactly one path matcher and classic choose [ ... ] apps are unaffected:
open Fable.Giraffe
open Fable.Giraffe.Endpoints
type User = { Name: string; Age: int }
let endpoints = [
GET [
route "/ping" (text "pong")
|> summary "Health check"
|> respondsWith<string> 200 "text/plain"
routef "/user/%i" getUser
|> pathParams [ "id" ]
|> responds<User> 200
|> respondsEmpty 404
]
POST [
route "/user" createUser
|> accepts<User>
|> responds<User> 201
]
// Metadata set on a group is inherited by its leaves.
subRoute "/admin" [ GET [ route "/stats" getStats |> responds<Stats> 200 ] ]
|> tags [ "admin" ]
]
// The annotation matters: without something consuming `webApp`, F#'s value
// restriction rejects the binding.
let webApp: HttpHandler =
endpoints
|> OpenApi.withDocs (OpenApiInfo.Create("My API", "1.0"))
|> Endpoints.toHandlerThat serves the document at /openapi.json and an interactive
Scalar UI at /docs. Path templates and
their parameter types are derived from the routef format string; response and
request schemas come from typeof<'T> captured at the call site. Types are
emitted once into components/schemas and referenced by $ref, so a shared type
is defined once and a recursive one round-trips.
The document is built once, at startup, and the handlers close over the rendered string — which is also what makes it work on BEAM, where Cowboy spawns a fresh process per request and only immutable values survive the hop.
Three things worth knowing:
- A route that answers any verb (one not inside a
GET [...]-style group) still routes, but is absent from the document — OpenAPI has no "any method" operation. routeftemplates carry no parameter names, so withoutpathParamsthey are named positionally (p0,p1).responds<'T>documentsapplication/json. A handler writingtext/plainshould userespondsWith<'T> 200 "text/plain", or the document will misdescribe it.
Remoting reflects over a record of ... -> Async<'T> fields and generates one
route per field, decoding arguments and encoding results with the same typed JSON
machinery:
type IServer =
{ getNumbers: unit -> Async<int list>
updateModel: Model -> Async<Model> }
let webApp =
Remoting.createApi ()
|> Remoting.fromValue server
|> Remoting.buildHttpHandlerA malformed body answers 400 with field-level errors; an exception raised by an
API method answers 500 without leaking it (Remoting.withErrorHandler replaces
that default).
- .NET SDK 8+
- Python >= 3.12 with uv
- Node.js 20+ (JavaScript target)
- Erlang/OTP 27+ with rebar3 (BEAM target)
just setup # restore dotnet tools + uv sync
just build # F# -> Python (output: build/lib/)
just build-js # F# -> JavaScript (output: build/js/)
just build-beam # F# -> Erlang (output: build/apps/giraffe/)For local Fable development (using a local Fable compiler checkout in ../Fable):
just dev=true buildEach of these compiles the example app in app/ and serves it on port 8080.
just app # Python — uvicorn
just app-js # JavaScript — node:http
just app-beam # Erlang/BEAM — CowboyOne shared behavioral suite in test/shared/ runs on all three targets:
just test # all three targets
just test-python # Python target
just test-js # JavaScript target
just test-beam # Erlang/BEAM target
just test-native # type-check the test projects on .NET (compile smoke only)Tests are written with Scriptorium —
Quill for the test DSL and runner, Nib for assertions — both of which compile to
every target. Per-target divergences are marked with skipIfBeam /
skipIfJavaScript next to the test, each carrying a comment explaining the gap,
so they show up as skips rather than silently disappearing.
There is no pure-.NET behavioral run: src is Fable-only bindings, so
test-native is a compile smoke test.
An identical /ping handler (returns pong) on every target, driven with
oha: 10,000 requests at 100 concurrent
connections over loopback, with no per-request logging on any target. The
.NET row is the original Giraffe on ASP.NET Core / Kestrel, included as a
reference point.
| Target | Requests/sec | Avg latency | P99 latency |
|---|---|---|---|
| .NET (reference) | ~321,000 | 0.29 ms | 1.33 ms |
| Erlang/BEAM (Cowboy) | ~224,000 | 0.42 ms | 1.71 ms |
| JavaScript (Node) | ~63,000 | 1.56 ms | 3.24 ms |
| Python (uvicorn, 1 worker) | ~14,600 | 6.79 ms | 11.90 ms |
These numbers are machine-dependent and only meaningful relative to one another;
throughput at the top of the table is noisy because the framework outruns the
loopback/oha harness driving it. The harness lives in perf/ — reproduce
with just bench (or just bench python js for a subset).
Every target must share the same logging configuration for the comparison to mean anything: an earlier version of this table ran with logging enabled on .NET but not BEAM, which made .NET look slower than BEAM. The current run turns per-request logging off everywhere.