Falco.UnionRoutes 0.3.0

dotnet add package Falco.UnionRoutes --version 0.3.0
                    
NuGet\Install-Package Falco.UnionRoutes -Version 0.3.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Falco.UnionRoutes" Version="0.3.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Falco.UnionRoutes" Version="0.3.0" />
                    
Directory.Packages.props
<PackageReference Include="Falco.UnionRoutes" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Falco.UnionRoutes --version 0.3.0
                    
#r "nuget: Falco.UnionRoutes, 0.3.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Falco.UnionRoutes@0.3.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Falco.UnionRoutes&version=0.3.0
                    
Install as a Cake Addin
#tool nuget:?package=Falco.UnionRoutes&version=0.3.0
                    
Install as a Cake Tool

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

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 Definition Path Notes
Health /health kebab-case from name
DigestView /digest-view kebab-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 Name Method Path Notes
Root GET / empty path
List GET / empty path
Create POST / empty path, POST method
Show GET /{params} param-only path
Member GET /{params} param-only path (alias for Show)
Edit GET /edit produces path segment
Delete DELETE /{params} DELETE method
Patch PATCH /{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 Type Constraint Example 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

Type Source Example
QueryParam<'T> Query string ?page=2
QueryParam<'T> option Optional query missing → None
PreCondition<'T> Precondition extractor auth, validation (strict)
OverridablePreCondition<'T> Skippable precondition child routes can opt out
Returns<'T> Response type metadata Route.respond returns value
JsonBody<'T> JSON request body deserialized via System.Text.Json
FormBody<'T> 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 [<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: Guid Path params not found in fields
Multiple nested unions Both of ChildA * ChildB Case has 2 nested route unions (max 1)
Multiple body fields Bad of JsonBody<A> * FormBody<B> At most 1 body field per case
Body + nested union Bad of JsonBody<A> * ChildRoute Body field cannot coexist with nested route

Uniqueness errors (checked by Route.validate and Route.endpoints):

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

Precondition errors (Route.validatePreconditions):

Error Example Message
Missing extractor PreCondition<UserId> field with no registered extractor Missing 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.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:

# Install as a global tool
dotnet tool install 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

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.3.0 84 2/24/2026
0.2.0 109 2/11/2026
0.1.2 106 2/9/2026
0.1.1 94 2/9/2026
0.1.0-alpha.7 48 2/9/2026
0.1.0-alpha.6 47 2/9/2026
0.1.0-alpha.5 72 2/6/2026
0.1.0-alpha.4 55 2/3/2026
0.1.0-alpha.3 48 2/2/2026
0.1.0-alpha.2 48 2/2/2026