Falco.UnionRoutes

June 16, 2026 · View on GitHub

Define your routes as F# discriminated unions. The goal: exhaustive pattern matching, type-safe links, and automatic parameter extraction, all driven by the route type. 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 it aims for:

  • Route params, query params, request bodies, and auth extracted from the field types, so handlers receive ready-to-use values.
  • Type-safe links: Route.link (Detail postId) -> "/posts/abc-123", checked by the compiler.
  • Exhaustive handlers: the match must cover every route, so a new case is a compile error until you handle it.

Status: early alpha. Substantially AI-written and still finding its shape. Behavior and APIs shift between versions, so expect rough edges — your mileage may vary. Issues and PRs welcome.

Installation

dotnet add package Falco.UnionRoutes

API Documentation

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 examples/ExampleApp/Program.fs for a complete working example. Run it with mise run example.

Route Conventions

Routing behavior:

Case DefinitionPathNotes
Health/healthkebab-case from name
DigestView/digest-viewkebab-case from name
Detail of id: Guid/{id:guid}field name -> path param + type constraint
ByPage of page: int/{page:int}int -> :int constraint
Edit of a: Guid * b: Guid/{a:guid}/{b:guid}multiple path params
Posts of PostRoute/posts/...nested DU -> path prefix
[<Route(Path = "")>] Api of ApiRoute/...path-less group

RESTful case names (no case name prefix in path):

Case NameMethodPathNotes
RootGET/empty path
ListGET/empty path
CreatePOST/empty path, POST method
ShowGET/{params}param-only path
MemberGET/{params}param-only path (alias for Show)
EditGET/editproduces path segment
DeleteDELETE/{params}DELETE method
PatchPATCH/{params}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 TypeConstraintExample Path
Guid:guid{id:guid}
int:int{page:int}
int64:long{id:long}
bool:bool{enabled:bool}
string(none){name}
Single-case DU (e.g. PostId of Guid)inner type's constraint{id:guid}

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

TypeSourceExample
QueryParam<'T>Query string?page=2
QueryParam<'T> optionOptional querymissing -> None
PreCondition<'T>Precondition extractorauth, validation (strict)
OverridablePreCondition<'T>Skippable preconditionchild routes can opt out
Returns<'T>Response type metadataRoute.respond returns value
JsonBody<'T>JSON request bodydeserialized via System.Text.Json
FormBody<'T>Form-encoded bodyform 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

A parent route wires the precondition in via an OverridablePreCondition field, which the children above can opt out of:

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):

ErrorExampleMessage
Invalid path characters[<Route(Path = "hello world")>]Invalid characters in path
Unbalanced braces[<Route(Path = "users/{id")>]Unbalanced braces in path
Duplicate path params[<Route(Path = "{id}/sub/{id}")>]Duplicate path parameters
Param/field mismatch[<Route(Path = "{userId}")>] Profile of id: GuidPath params not found in fields
Multiple nested unionsBoth of ChildA * ChildBCase has 2 nested route unions (max 1)
Multiple body fieldsBad of JsonBody<A> * FormBody<B>At most 1 body field per case
Body + nested unionBad of JsonBody<A> * ChildRouteBody field cannot coexist with nested route

Uniqueness errors (Route.validateUniqueness, also run by Route.validate and Route.endpoints):

ErrorExampleMessage
Duplicate routesById of id: Guid + BySlug of slug: string both at GET /items/{_}Duplicate route: 'ById' and 'BySlug' both resolve to...
Ambiguous routesGET /{cat}/new vs GET /posts/{action} (neither is more specific)Ambiguous routes: ... overlap with no clear specificity winner

Precondition errors (Route.validatePreconditions):

ErrorExampleMessage
Missing extractorPreCondition<UserId> field with no registered extractorMissing preconditions for: PreCondition<UserId>

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.createMatcher<Route>()         // Pre-compiled URL matcher (reusable)
Route.matchUrl<Route> method url     // One-shot URL matching
Route.validateStructure<Route>()     // Validate path structure only
Route.validateUniqueness<Route>()    // Detect duplicate/ambiguous routes
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)

Route.link route returns the path only — it does not append a query string. Query-string fields (QueryParam<'T> and its option), body fields (JsonBody<'T>, FormBody<'T>), preconditions, and Returns<'T> are all excluded from the generated URL; only path-segment fields are substituted. A route's QueryParam value is therefore not reflected in its link. If you need a query string, append it yourself:

Route.link route + "?page=2"   // add the query string manually

Substituted path values are percent-encoded, so a link round-trips exactly through Route.matchUrl.

URL Matching

Match URL strings back into strongly-typed route values — the reverse of Route.link. Useful for testing, request routing outside Falco, deep-link handling, or URL validation.

// Create a pre-compiled matcher (recommended for matching many URLs)
let matcher = Route.createMatcher<Route>()

// Match returns the actual route value with parsed parameters
match matcher.Match(HttpMethod.Get, "/posts/550e8400-e29b-41d4-a716-446655440000") with
| Ok (Posts (Detail id)) -> printfn "Post detail: %O" id  // id is the actual Guid
| Ok route -> printfn "Matched: %A" route
| Error Route.NoMatchingRoute -> printfn "No route matched"
| Error (Route.ParameterError(path, name, value, expected)) ->
    printfn "Parameter '%s' value '%s' is not a valid %s" name value expected

// One-shot convenience (creates a new matcher each call)
let result = Route.matchUrl<Route> HttpMethod.Get "/health"

Features:

  • Parameters are parsed to their actual types (Guid, int, etc.)
  • Single-case DU wrappers (e.g., PostId of Guid) are reconstructed
  • Case-insensitive path matching
  • Query strings are stripped before matching
  • Nested route hierarchies are fully supported

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:

# Install as a global tool (command: falco-routes)
dotnet tool install --global Falco.UnionRoutes.Cli

# Generate to stdout (auto-detects route type)
falco-routes MyApp/MyApp.fsproj --title "My API"

# Specify route type and output file
falco-routes MyApp/MyApp.fsproj MyApp.Route --output openapi.json

# Skip build if already compiled
falco-routes MyApp/MyApp.fsproj --no-build --title "My API" --version "2.0.0"

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