Flame 0.1.0-alpha.9
dotnet add package Flame --version 0.1.0-alpha.9
NuGet\Install-Package Flame -Version 0.1.0-alpha.9
<PackageReference Include="Flame" Version="0.1.0-alpha.9" />
<PackageVersion Include="Flame" Version="0.1.0-alpha.9" />
<PackageReference Include="Flame" />
paket add Flame --version 0.1.0-alpha.9
#r "nuget: Flame, 0.1.0-alpha.9"
#:package Flame@0.1.0-alpha.9
#addin nuget:?package=Flame&version=0.1.0-alpha.9&prerelease
#tool nuget:?package=Flame&version=0.1.0-alpha.9&prerelease
Flame
Schema validation for F#. Parse, validate, and transform JSON in a single pass with zero-allocation performance.
Install
dotnet add package Flame --prerelease
Quick Start
Define an F# record type. Flame generates a schema automatically — option fields become optional, nested records and typed lists are handled recursively:
open Flame
type Address = { Street: string; City: string; Zip: string }
type Tag = { Key: string; Value: string }
type CreateUser = {
Name: string // required, rejects empty strings
Email: string // required, rejects empty strings
Age: int // required
Address: Address // required nested object
Tags: Tag list // required list of nested objects
Bio: string option // optional, defaults to None
}
let userSchema = Schema.fromType<CreateUser>()
Parse JSON in one call:
let json = """
{
"Name": "Alice",
"Email": "alice@example.com",
"Age": 30,
"Address": { "Street": "123 Main St", "City": "Springfield", "Zip": "62701" },
"Tags": [{ "Key": "role", "Value": "admin" }],
"Bio": "Hello world"
}"""
match Schema.parseString userSchema json with
| Ok user -> printfn $"Hello, {user.Name} from {user.Address.City}"
| Error errors -> errors |> List.iter (printfn " %s")
That's it. No boilerplate, no manual field mapping, no separate validation step. Flame handles required/optional detection, nested records, typed lists, and error reporting with dotted paths (Address.Zip is required).
A more complete example
A product catalog API with nested types, lists, and optional fields:
open Flame
// Domain types
type Dimension = { Width: float; Height: float; Depth: float; Unit: string }
type Image = { Url: string; Alt: string option }
type Variant = { Sku: string; Color: string; Size: string; Price: float; Stock: int }
type CreateProduct = {
Name: string // required, rejects empty
Description: string option // optional
Category: string // required
Dimensions: Dimension option // optional nested object
Images: Image list // required list of nested objects
Variants: Variant list // required list of nested objects
Draft: bool option // optional, defaults to None
}
// Schema is generated once and cached
let productSchema = Schema.fromType<CreateProduct>()
// Parse a request body
let json = """
{
"Name": "Standing Desk",
"Category": "furniture",
"Dimensions": { "Width": 120.0, "Height": 75.0, "Depth": 60.0, "Unit": "cm" },
"Images": [
{ "Url": "https://cdn.example.com/desk-1.jpg", "Alt": "Front view" },
{ "Url": "https://cdn.example.com/desk-2.jpg" }
],
"Variants": [
{ "Sku": "DESK-BLK-S", "Color": "black", "Size": "small", "Price": 399.99, "Stock": 12 },
{ "Sku": "DESK-WHT-L", "Color": "white", "Size": "large", "Price": 499.99, "Stock": 5 }
],
"Draft": true
}"""
match Schema.parseString productSchema json with
| Ok product ->
printfn $"{product.Name} — {product.Variants.Length} variants, {product.Images.Length} images"
for v in product.Variants do
printfn $" {v.Sku}: ${v.Price} ({v.Stock} in stock)"
| Error errors ->
printfn "Validation errors:"
for e in errors do printfn $" {e}"
If you need validation rules on top of the type structure, use the schema { } CE instead — see Adding Validation Rules.
Type mapping
| F# Type | Behavior |
|---|---|
string |
Required. Rejects empty strings. |
int, float, bool |
Required. |
DateTime, DateTimeOffset |
Required. Parses ISO 8601. |
string option, int option, etc. |
Optional. Defaults to None. |
string list, int list, etc. |
Required typed list. |
Record list |
Required. Each item parsed recursively. |
| Nested record | Required nested object, parsed recursively. |
Record option |
Optional nested object. |
fromType works with both named records and anonymous records:
let schema = Schema.fromType<{| Name: string; Score: float option |}>()
Results are cached per type — reflection only runs on the first call.
Adding Validation Rules
When you need more than type-level validation — length limits, format checks, transforms — use the schema { } computation expression:
let createUserSchema = schema {
let! name = Schema.required "name" Schema.string [ Schema.nonempty; Schema.maxLength 100; Schema.trim ]
let! email = Schema.required "email" Schema.string [ Schema.email; Schema.trim; Schema.lowercase ]
let! age = Schema.optional "age" Schema.int 0 [ Schema.min 0; Schema.max 150 ]
return {| Name = name; Email = email; Age = age |}
}
Each let! binds a field with a name, a type parser, and a list of rules. The return assembles the typed result.
Required vs optional
// Required: must be present and non-null
let! title = Schema.required "title" Schema.string [ Schema.nonempty; Schema.maxLength 200 ]
// Optional: can be omitted or null, falls back to default. Rules apply when present.
let! priority = Schema.optional "priority" Schema.string "medium" [ Schema.enum' ["low"; "medium"; "high"] ]
Rule chaining
Rules are applied left to right. Each rule receives the output of the previous rule. Transforms should come before validators:
// 1. trim whitespace 2. check not empty 3. lowercase
let! email = Schema.required "email" Schema.string [ Schema.trim; Schema.nonempty; Schema.lowercase ]
If a rule fails, the error is collected but subsequent rules still run. Flame never short-circuits — all errors are reported.
String rules
Schema.minLength 3 // at least 3 characters
Schema.maxLength 200 // at most 200 characters
Schema.length 5 // exactly 5 characters
Schema.nonempty // must not be empty
Schema.pattern @"^\d{5}$" // must match regex
Schema.email // must contain @ and .
Schema.url // must start with http:// or https://
Schema.uuid // must be a valid UUID/GUID
Schema.ip // must be a valid IP address (v4 or v6)
Schema.ipv4 // must be a valid IPv4 address
Schema.ipv6 // must be a valid IPv6 address
Schema.datetime // must be a valid ISO 8601 date/time string
Schema.startsWith "https" // must start with prefix
Schema.endsWith ".com" // must end with suffix
Schema.includes "@" // must contain substring
Schema.enum' ["a"; "b"; "c"] // must be one of the listed values
String transforms
Transforms modify the value. They always succeed and pass the transformed value to the next rule.
Schema.trim // remove leading/trailing whitespace
Schema.lowercase // convert to lowercase (invariant)
Schema.uppercase // convert to uppercase (invariant)
Number rules
Schema.min 0.0 // >= 0 (inclusive)
Schema.max 100.0 // <= 100 (inclusive)
Schema.gt 0.0 // > 0 (exclusive)
Schema.lt 100.0 // < 100 (exclusive)
Schema.positive // > 0
Schema.negative // < 0
Schema.nonnegative // >= 0
Schema.nonpositive // <= 0
Schema.int' // must be a whole number (e.g. 5.0 ok, 5.5 fails)
Schema.multipleOf 0.5 // must be divisible by 0.5
Array rules
Applied to the parsed list after all items have been individually validated:
Schema.minItems 1 // at least 1 item
Schema.maxItems 10 // at most 10 items
Schema.nonEmpty // must have at least 1 item
Type Parsers
Flame provides parsers for common types. Each parser handles coercion from strings automatically — "42" parses as int, "true" as bool, etc. Coercion is consistent across all parsing paths.
| Parser | F# Type | Accepts |
|---|---|---|
Schema.string |
string |
JSON strings |
Schema.int |
int |
JSON numbers, numeric strings ("42") |
Schema.float |
float |
JSON numbers, numeric strings ("3.14") |
Schema.bool |
bool |
JSON true/false, strings "true"/"false" |
Schema.dateTime |
DateTime |
ISO 8601 strings |
Schema.dateTimeOffset |
DateTimeOffset |
ISO 8601 with offset |
Schema.list parser |
'T list |
JSON arrays |
Schema.nullable parser |
'T option |
JSON null becomes None |
Schema.nest schema |
nested object | JSON objects |
Lists
let! tags = Schema.required "tags" (Schema.list Schema.string) [ Schema.nonEmpty; Schema.maxItems 10 ]
let! scores = Schema.required "scores" (Schema.list Schema.int) []
Nullable fields
let! note = Schema.required "note" (Schema.nullable Schema.string) []
// note : string option — JSON null becomes None, a string becomes Some "..."
Lists of nested objects
let itemSchema = schema {
let! name = Schema.required "name" Schema.string [ Schema.nonempty ]
let! qty = Schema.required "qty" Schema.int [ Schema.positive ]
return {| Name = name; Qty = qty |}
}
let orderSchema = schema {
let! items = Schema.required "items" (Schema.list (Schema.nest itemSchema)) [ Schema.nonEmpty ]
return {| Items = items |}
}
Nested Schemas
Compose schemas with Schema.nest to validate nested JSON objects:
let addressSchema = schema {
let! street = Schema.required "street" Schema.string [ Schema.nonempty ]
let! city = Schema.required "city" Schema.string [ Schema.nonempty ]
let! zip = Schema.required "zip" Schema.string [ Schema.pattern @"^\d{5}$" ]
return {| Street = street; City = city; Zip = zip |}
}
let userSchema = schema {
let! name = Schema.required "name" Schema.string [ Schema.nonempty ]
let! address = Schema.required "address" (Schema.nest addressSchema) []
return {| Name = name; Address = address |}
}
Errors from nested schemas use dotted paths: address.zip: must match pattern ^\d{5}$
Cross-field Validation
Use Schema.check with do! to validate relationships between fields:
let dateRangeSchema = schema {
let! startDate = Schema.required "start" Schema.dateTime []
let! endDate = Schema.required "end" Schema.dateTime []
do! Schema.check (fun () ->
if endDate > startDate then Ok ()
else Error "end: must be after start"
)
return {| Start = startDate; End = endDate |}
}
Multiple checks can be chained:
let registrationSchema = schema {
let! password = Schema.required "password" Schema.string [ Schema.minLength 8 ]
let! confirm = Schema.required "confirm" Schema.string []
do! Schema.check (fun () ->
if password = confirm then Ok () else Error "confirm: must match password"
)
return {| Password = password |}
}
Note: schemas with Schema.check use the element parsing path instead of the zero-alloc buffer path, since cross-field checks use closures that capture field values.
Parsing
All parse functions return Result<'T, string list>.
| Function | Input | Notes |
|---|---|---|
Schema.parseString |
string |
Default. Uses Utf8JsonReader internally. |
Schema.parseBuffer |
ReadOnlySequence<byte> |
Zero-alloc. Best for Kestrel request bodies. |
Schema.parseJson |
JsonElement |
When you already have a JsonDocument. |
Schema.parseStream |
Stream |
Async. For request body streams. |
Schema.parsePipe |
PipeReader |
Async. For Kestrel's PipeReader. |
Schema.parseLookup |
string -> string option |
Zero-alloc. For query strings, route params, form data. |
Schema.parseMap |
IReadOnlyDictionary<string, string> |
Delegates to parseLookup. |
Two parsing paths
Flame has two internal paths. The buffer path (parseString, parseBuffer) parses directly from raw bytes using Utf8JsonReader with ArrayPool<obj> — no JsonDocument allocated. The element path (parseJson, parseStream) works with JsonElement. Schemas with Schema.check fall back to the element path.
Parsing from query strings and forms
parseLookup skips JSON entirely — values are coerced from strings to the target type:
let paginationSchema = schema {
let! page = Schema.optional "page" Schema.int 1 [ Schema.positive ]
let! limit = Schema.optional "limit" Schema.int 20 [ Schema.min 1; Schema.max 100 ]
return {| Page = page; Limit = limit |}
}
let result = Schema.parseLookup paginationSchema (fun name ->
match name with
| "page" -> Some "2"
| "limit" -> Some "50"
| _ -> None
)
Error Handling
Flame collects all validation errors — it never short-circuits. This lets users fix all problems at once.
match Schema.parseString mySchema """{"name":"","email":"bad","age":-1}""" with
| Error errors ->
// [
// "name: must not be empty"
// "email: invalid email format"
// "age: must be at least 0"
// ]
| Situation | Format | Example |
|---|---|---|
| Field validation | "field: message" |
"email: invalid email format" |
| Missing required field | "field is required" |
"name is required" |
| Nested field | "parent.field: message" |
"address.zip: must match pattern ^\d{5}$" |
| List item | "field.[index]: message" |
"items.[2]: expected integer" |
JSON Schema Generation
Generate standard JSON Schema from any Flame schema:
let jsonSchema = Schema.toJsonSchema createUserSchema
{
"type": "object",
"properties": {
"name": { "type": "string", "minLength": 1, "maxLength": 100 },
"email": { "type": "string", "format": "email" },
"age": { "type": "integer", "minimum": 0, "maximum": 150 }
},
"required": ["name", "email"]
}
All validators emit correct JSON Schema keywords: minLength, maxLength, pattern, minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf, minItems, maxItems, format, enum. Nested objects include properties and required. Arrays include items.
Validating Existing Values
Use validator { } to validate an F# value that already exists — no JSON involved:
let userValidator = validator {
validate "Name" (fun (u: User) -> u.Name) [ Schema.nonempty; Schema.maxLength 100 ]
validate "Email" (fun u -> u.Email) [ Schema.email ]
validate "Address.Zip" (fun u -> u.Address.Zip) [ Schema.pattern @"^\d{5}$" ]
}
match Validator.validate userValidator someUser with
| Ok user -> // valid
| Error errors -> // ["Name: must not be empty"; "Address.Zip: must match pattern ..."]
Uses the same rules as schema { } but works directly on typed values via lambdas. No JSON, no reflection at runtime.
Validator.fromType auto-generates a validator from a record type (required strings get nonempty, nested records recurse):
let userValidator = Validator.fromType<User>()
Validate and Transform
Use validated { } to validate an input and produce a different output type in one step. Transforms (trim, lowercase) are applied to the output values:
type CreateUserInput = { Name: string; Email: string; Age: int }
type ValidUser = { Name: string; Email: string; Age: int }
let createUser = validated {
let! name = Validated.field "Name" (fun (r: CreateUserInput) -> r.Name) [ Schema.nonempty; Schema.trim ]
let! email = Validated.field "Email" (fun r -> r.Email) [ Schema.email; Schema.trim; Schema.lowercase ]
let! age = Validated.field "Age" (fun r -> r.Age) [ Schema.min 0; Schema.max 150 ]
return { ValidUser.Name = name; Email = email; Age = age }
}
match Validated.run createUser { Name = " Alice "; Email = " ALICE@TEST.COM "; Age = 30 } with
| Ok user -> // { Name = "Alice"; Email = "alice@test.com"; Age = 30 }
| Error errors -> // all validation errors collected
Domain-Driven Design with value objects
The validated { } CE works naturally with single-case discriminated unions for domain modeling. Validation and domain construction happen in one step:
// Value objects
type Name = Name of string
type Email = Email of string
type Age = Age of int
// Domain model
type ValidUser = { Name: Name; Email: Email; Age: Age }
// Input DTO
type CreateUserRequest = { Name: string; Email: string; Age: int }
let createUser = validated {
let! name = Validated.field "Name" (fun (r: CreateUserRequest) -> r.Name) [ Schema.nonempty; Schema.maxLength 100; Schema.trim ]
let! email = Validated.field "Email" (fun r -> r.Email) [ Schema.email; Schema.trim; Schema.lowercase ]
let! age = Validated.field "Age" (fun r -> r.Age) [ Schema.positive; Schema.max 150 ]
return { Name = Name name; Email = Email email; Age = Age age }
}
// DTO in, validated domain model out:
match Validated.run createUser request with
| Ok user -> // user.Email = Email "alice@test.com"
| Error errors -> // ["Email: invalid email format"; "Age: must be positive"]
Performance
Flame parses and validates in a single pass using Utf8JsonReader with ArrayPool<obj>. No intermediate JsonDocument on the buffer path.
Benchmarks on Apple M4 Pro, .NET 10:
| Scenario | Flame (buffer) | FluentValidation (STJ + validate) | Speedup | Memory |
|---|---|---|---|---|
| 3 fields + rules | 239 ns / 408 B | 266 ns / 832 B | 1.1x | 2x less |
| 10 fields + nested | 763 ns / 1,448 B | 1,098 ns / 2,856 B | 1.4x | 2x less |
| Nested objects | 370 ns / 824 B | 509 ns / 2,136 B | 1.4x | 2.6x less |
| uuid + ip + array validators | 516 ns / 944 B | 566 ns / 1,800 B | 1.1x | 1.9x less |
dotnet run --project benchmarks/Flame.Benchmarks -c Release
License
MIT
| Product | Versions 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. |
-
net10.0
- FSharp.Core (>= 10.0.100)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Flame:
| Package | Downloads |
|---|---|
|
Firefly.Server
A minimal F# web framework built on Kestrel |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.1.0-alpha.9 | 62 | 3/31/2026 |
| 0.1.0-alpha.8 | 35 | 3/31/2026 |
| 0.1.0-alpha.6 | 37 | 3/31/2026 |
| 0.1.0-alpha.5 | 40 | 3/31/2026 |
| 0.1.0-alpha.4 | 41 | 3/31/2026 |
| 0.1.0-alpha.3 | 43 | 3/31/2026 |
| 0.1.0-alpha.2 | 45 | 3/31/2026 |
| 0.1.0-alpha | 42 | 3/31/2026 |