Falco.UnionRoutes
Define your routes as F# discriminated unions. Get exhaustive pattern matching, type-safe links, and automatic parameter extraction. Inspired by Haskell's Servant library.
type PostRoute =
| List of page: QueryParam<int> option // GET /posts?page=1
| Detail of id: PostId // GET /posts/{id}
| Create of JsonBody<PostInput> * PreCondition<UserId> // POST /posts (JSON body + auth)
let handlePost route : HttpHandler =
match route with
| List page -> Response.ofJson (getPosts page)
| Detail postId -> Response.ofJson (getPost postId)
| Create (JsonBody input, PreCondition userId) -> Response.ofJson (createPost userId input)
What you get:
- automatic extraction, parsing of query params. Handlers don't need to parse query params or check if user is logged in, etc.
- Route.link (Detail postId) -> "/posts/abc-123" (type-checked)
- Route/query params and auth automatically extracted based on field types
Installation
|
How It Works
Routes are discriminated unions. Field names become URL parameters:
type PostRoute =
| List // GET /posts
| Detail of id: Guid // GET /posts/{id}
| Create // POST /posts (convention: Create -> POST)
| Delete of id: Guid // DELETE /posts/{id} (convention: Delete -> DELETE)
Special marker types change where values come from:
| Search of query: QueryParam<string> // GET /posts/search?query=hello
| Search of q: QueryParam<string> option // optional query param
| Create of PreCondition<UserId> // UserId from auth extractor, not URL
| Edit of PreCondition<UserId> * id: Guid // auth + route param
| Admin of OverridablePreCondition<AdminId> * data // skippable precondition (child routes can opt out)
| Create of JsonBody<PostInput> * PreCondition<UserId> // JSON body + auth
| Submit of FormBody<LoginInput> // form-encoded body
Single-case wrapper DUs are auto-unwrapped:
type PostId = PostId of Guid
| Detail of id: PostId // extracts Guid from URL, wraps in PostId
Basic Usage
// 1. Define routes
type Route =
| Home // GET /home
| Posts of PostRoute // /posts/...
| [<Route(Path = "")>] Admin of AdminRoute
type PostInput = { Title: string; Body: string }
type PostRoute =
| List of page: QueryParam<int> option // GET /posts?page=1
| Detail of id: PostId // GET /posts/{id}
| Create of JsonBody<PostInput> * PreCondition<UserId> // POST /posts (JSON body + auth)
type AdminRoute =
| Dashboard of PreCondition<AdminId> // GET /dashboard
// 2. Configure extraction — extractors are async (HttpContext -> Task<Result<'T, 'E>>)
let requireAuth : Extractor<UserId, AppError> = fun ctx ->
Task.FromResult(
match ctx.User.FindFirst(ClaimTypes.NameIdentifier) with
| null -> Error NotAuthenticated
| claim -> Ok (UserId (Guid.Parse claim.Value)))
let requireAdmin : Extractor<AdminId, AppError> = fun ctx ->
Task.FromResult(
if ctx.User.IsInRole("Admin") then
match ctx.User.FindFirst(ClaimTypes.NameIdentifier) with
| null -> Error NotAuthenticated
| claim -> Ok (AdminId (Guid.Parse claim.Value))
else Error (Forbidden "Admin role required"))
let config: EndpointConfig<AppError> = {
Preconditions =
[ yield! Extractor.precondition<UserId, AppError> requireAuth
yield! Extractor.precondition<AdminId, AppError> requireAdmin ]
Parsers = []
MakeError = fun msg -> BadRequest msg
CombineErrors = fun errors -> errors |> List.head
ToErrorResponse = fun e -> Response.withStatusCode 400 >> Response.ofPlainText (string e)
}
// 3. Handle routes (compiler ensures exhaustive, routes already hydrated)
let handlePost route : HttpHandler =
match route with
| List page -> Response.ofJson (getPosts page)
| Detail postId -> Response.ofJson (getPost postId)
| Create (JsonBody input, PreCondition userId) -> Response.ofJson (createPost userId input)
let handleRoute route : HttpHandler =
match route with
| Home -> Response.ofPlainText "home"
| Posts p -> handlePost p
| Admin (Dashboard (PreCondition adminId)) -> Response.ofPlainText "admin"
// 4. Generate endpoints - extraction happens automatically
let endpoints = Route.endpoints config handleRoute
Reference
See the Example App for a complete working example. Run it with mise run example.
Route Conventions
Routing behavior:
Case Definition |
Path |
Notes |
|---|---|---|
|
|
kebab-case from name |
|
|
kebab-case from name |
|
|
field name -> path param + type constraint |
|
|
int -> |
|
|
multiple path params |
|
|
nested DU -> path prefix |
|
|
path-less group |
RESTful case names (no case name prefix in path):
Case Name |
Method |
Path |
Notes |
|---|---|---|---|
|
GET |
|
empty path |
|
GET |
|
empty path |
|
POST |
|
empty path, POST method |
|
GET |
|
param-only path |
|
GET |
|
param-only path (alias for Show) |
|
GET |
|
produces path segment |
|
DELETE |
|
DELETE method |
|
PATCH |
|
PATCH method |
Override with attributes:
[<Route(RouteMethod.Put)>] // just method
[<Route(Path = "custom/{id}")>] // just path
[<Route(RouteMethod.Put, Path = "custom/{id}")>] // both
[<Route(Constraints = [| RouteConstraint.Alpha |], MinLength = 3, MaxLength = 50)>] // constraints
[<Route(MinValue = 1, MaxValue = 100)>] // integer range
[<Route(Pattern = @"^\d{3}-\d{4}$")>] // regex pattern
Implicit type constraints — applied automatically based on field types:
Field Type |
Constraint |
Example Path |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
(none) |
|
Single-case DU (e.g. |
inner type's constraint |
|
Implicit and explicit constraints combine: a Guid field with [<Route(Constraints = [| Required |])>] produces {id:guid:required}.
Parser constraints — applied by Route.endpoints when custom parsers declare constraints:
Extractor.constrainedParser<Slug> [| RouteConstraint.Alpha |] parseFn // adds :alpha
Extractor.typedParser<bool, ToggleState> parseFn // adds :bool (from input type)
Parser constraints are applied at endpoint registration time. Route.info and Route.link reflect only type-based constraints since they don't require EndpointConfig.
Marker Types
Type |
Source |
Example |
|---|---|---|
|
Query string |
|
|
Optional query |
missing -> |
|
Precondition extractor |
auth, validation (strict) |
|
Skippable precondition |
child routes can opt out |
|
Response type metadata |
|
|
JSON request body |
deserialized via |
|
Form-encoded body |
form fields mapped to record |
Preconditions
OverridablePreCondition<'T> lets child routes opt out with attributes:
type ItemRoute =
| List // inherits preconditions
| [<SkipAllPreconditions>] Public // skips all overridable preconditions
| [<SkipPrecondition(typeof<UserId>)>] Limited // skips specific one
type Route =
| Items of userId: UserId * OverridablePreCondition<UserId> * ItemRoute
PreCondition<'T>— strict, always runs, cannot be skipped-
OverridablePreCondition<'T>— skippable via[<SkipAllPreconditions>]or[<SkipPrecondition(typeof<T>)>]
Nested Routes
type PostDetailRoute =
| Show // GET /posts/{id}
| Edit // GET /posts/{id}/edit
| Delete // DELETE /posts/{id}
| Patch // PATCH /posts/{id}
type PostRoute =
| List of page: QueryParam<int> option // GET /posts?page=1
| Create of JsonBody<PostInput> * PreCondition<UserId> // POST /posts (JSON body + auth)
| Search of query: QueryParam<string> // GET /posts/search?query=hello
| Member of id: Guid * PostDetailRoute // /posts/{id}/...
Member produces a param-only path (no case-name prefix). Show/Delete/Patch collapse to the same path with different methods. Edit produces /edit.
Route Validation
Route.endpoints automatically validates at startup and will fail fast with descriptive errors. Route.validate combines all checks for use in tests.
Structure errors (Route.validateStructure):
Error |
Example |
Message |
|---|---|---|
Invalid path characters |
|
Invalid characters in path |
Unbalanced braces |
|
Unbalanced braces in path |
Duplicate path params |
|
Duplicate path parameters |
Param/field mismatch |
|
Path params not found in fields |
Multiple nested unions |
|
Case has 2 nested route unions (max 1) |
Multiple body fields |
|
At most 1 body field per case |
Body + nested union |
|
Body field cannot coexist with nested route |
Uniqueness errors (checked by Route.validate and Route.endpoints):
Error |
Example |
Message |
|---|---|---|
Duplicate routes |
|
Duplicate route: 'ById' and 'BySlug' both resolve to... |
Ambiguous routes |
|
Ambiguous routes: ... overlap with no clear specificity winner |
Precondition errors (Route.validatePreconditions):
Error |
Example |
Message |
|---|---|---|
Missing extractor |
|
Missing preconditions for: PreCondition\ |
Routes with overlapping patterns are automatically sorted by specificity (/posts/new before /posts/{id}).
[<Fact>]
let ``all routes are valid`` () =
let result = Route.validate<Route, AppError> config.Preconditions
Assert.Equal(Ok (), result)
Key Functions
// Route module
Route.endpoints config handler // Generate endpoints with extraction (main entry point)
Route.respond returns value // Type-safe JSON response via Returns<'T>
Route.link route // Type-safe URL: "/posts/abc-123"
Route.info route // RouteInfo with Method and Path
Route.allRoutes<Route>() // Enumerate all routes
Route.validateStructure<Route>() // Validate path structure only
Route.validatePreconditions<Route, Error> preconditions // Check precondition coverage
Route.validate<Route, Error> preconditions // Full validation (for tests)
// EndpointConfig record (passed to Route.endpoints)
{ Preconditions = [...] // Auth/validation extractors
Parsers = [...] // Custom type parsers
MakeError = fun msg -> ... // String -> error type
CombineErrors = fun errors -> ... // Combine multiple errors
ToErrorResponse = fun e -> ... } // Error -> HTTP response
// Extractor module - create extractors for EndpointConfig
// Extractors are async: Extractor<'T,'E> = HttpContext -> Task<Result<'T,'E>>
Extractor.precondition<UserId, Error> extractFn // Both PreCondition + OverridablePreCondition
Extractor.preconditionSync<UserId, Error> syncExtractFn // Sync convenience wrapper
Extractor.parser<Slug> parseFn // For custom types (string input)
Extractor.constrainedParser<Slug> [| Alpha |] parseFn // String parser + route constraints
Extractor.typedParser<bool, Toggle> parseFn // Typed parser (pre-parsed input)
OpenAPI Spec Generation
Generate OpenAPI 3.0 JSON from your route types — useful for documentation, client generation, or API gateways.
Programmatic (library):
let spec = Spec.generate<Route> { Title = "My API"; Version = "1.0.0"; Description = None }
printfn "%s" spec
CLI tool:
|
The CLI builds the project, loads the output assembly, and auto-detects the root route type. If multiple root route types exist, specify which one as the second argument.
License
MIT
module List from Microsoft.FSharp.Collections
--------------------
type List<'T> = | op_Nil | op_ColonColon of Head: 'T * Tail: 'T list interface IReadOnlyList<'T> interface IReadOnlyCollection<'T> interface IEnumerable interface IEnumerable<'T> member GetReverseIndex: rank: int * offset: int -> int member GetSlice: startIndex: int option * endIndex: int option -> 'T list static member Cons: head: 'T * tail: 'T list -> 'T list member Head: 'T with get member IsEmpty: bool with get member Item: index: int -> 'T with get ...
val int: value: 'T -> int (requires member op_Explicit)
--------------------
type int = int32
--------------------
type int<'Measure> = int
union case PostRoute.List: page: obj -> PostRoute
--------------------
module List from Microsoft.FSharp.Collections
--------------------
type List<'T> = | op_Nil | op_ColonColon of Head: 'T * Tail: 'T list interface IReadOnlyList<'T> interface IReadOnlyCollection<'T> interface IEnumerable interface IEnumerable<'T> member GetReverseIndex: rank: int * offset: int -> int member GetSlice: startIndex: int option * endIndex: int option -> 'T list static member Cons: head: 'T * tail: 'T list -> 'T list member Head: 'T with get member IsEmpty: bool with get member Item: index: int -> 'T with get ...
val string: value: 'T -> string
--------------------
type string = System.String
Falco.UnionRoutes