Simple, explicit JSON parsing for F# using System.Text.Json.
Inspired by Thoth.Json and its composability.
Farse uses a slightly different syntax, includes a computation expression, and a few custom operators that simplify parsing. It also tries to keep a low overhead while producing detailed errors.
dotnet package add FarseThe benchmarks can be found here.
BenchmarkDotNet v0.15.8, macOS Tahoe 26.4.1 (25E253) [Darwin 25.4.0]
Apple M1 Pro, 1 CPU, 8 logical and 8 physical cores
.NET SDK 10.0.201
[Host] : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a DEBUG
DefaultJob : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a| Method | Mean | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio |
|----------------------- |---------:|------:|---------:|--------:|----------:|------------:|
| System.Text.Json | 122.2 us | 0.77 | 6.1035 | - | 37.57 KB | 0.68 |
| Farse | 157.7 us | 1.00 | 9.0332 | - | 55.58 KB | 1.00 |
| System.Text.Json* | 130.3 us | 0.83 | 17.3340 | 2.6855 | 106.53 KB | 1.92 |
| Newtonsoft.Json* | 222.2 us | 1.41 | 48.8281 | 8.5449 | 299.77 KB | 5.39 |
| Thoth.System.Text.Json | 250.0 us | 1.59 | 68.8477 | 22.4609 | 423.88 KB | 7.63 |
| Newtonsoft.Json | 259.9 us | 1.65 | 86.9141 | 40.0391 | 534.38 KB | 9.61 |
| Thoth.Json.Net | 365.7 us | 2.32 | 111.3281 | 55.6641 | 684.98 KB | 12.32 |
* SerializationThe complete example can be found here.
Given the JSON string.
{
"id": "c8eae96a-025d-4bc9-88f8-f204e95f2883",
"name": "Alice",
"age": null,
"email": "[email protected]",
"profiles": [
"01458283-b6e3-4ae7-ae54-a68eb587cdc0",
"927eb20f-cd62-470c-aafc-c3ce6b9248b0",
"bf00d1e2-ee53-4969-9507-86bed7e96432"
],
"subscription": {
"plan": "pro",
"isCanceled": false,
"renewsAt": "2026-12-25T10:30:00Z"
},
"tags": [
"beta",
"verified"
]
}And the two custom operators.
// Parses a required property.
let (&=) = Prop.req
// Parses an optional property.
let (?=) = Prop.optWe can create this simple parser.
open Farse
module User =
open Operators
open Parse
let parser =
parser {
let! id = "id" &= guid |> Parser.map UserId
and! name = "name" &= string
and! age = "age" ?= valid byte Age.fromByte
and! email = "email" &= valid string Email.fromString
and! profiles = "profiles" &= set profileId // Custom parser example.
and! tags = "tags" &= list (valid string Tag.fromString)
// Inlined parser example.
and! subscription = "subscription" &= parser {
let! plan = "plan" &= valid string Plan.fromString
and! isCanceled = "isCanceled" &= bool
and! renewsAt = "renewsAt" ?= instant // Custom parser example.
return {
Plan = plan
IsCanceled = isCanceled
RenewsAt = renewsAt
}
}
// "Path" example, which can be very useful
// when we just want to parse a (few) nested value(s).
and! _isCanceled = "subscription.isCanceled" &= bool
return {
Id = id
Name = name
Age = age
Email = email
Profiles = profiles
Subscription = subscription
Tags = tags
}
}Note: The custom parsers are defined under the same module name as included parsers.
For the following types.
type UserId = UserId of Guid
module UserId =
let asString (UserId x) =
string x
type Age = Age of byte
module Age =
[<Literal>]
let private MinAge = 12uy
let fromByte = function
| age when age >= MinAge -> Ok <| Age age
| _ -> Error $"The minimum age is '%u{MinAge}'."
let asByte (Age x) = x
type Email = Email of string
module Email =
let fromString =
// Some validation.
Email >> Ok
let asString (Email x) = x
type ProfileId = ProfileId of Guid
module ProfileId =
let asString (ProfileId x) =
string x
type Plan =
| Pro
| Standard
| Free
module Plan =
let fromString = function
| "Pro" -> Ok Pro
| "Standard" -> Ok Standard
| "Free" -> Ok Free
| str -> Error $"Plan '%s{str}' not found."
let asString = function
| Pro -> "Pro"
| Standard -> "Standard"
| Free -> "Free"
type Subscription = {
Plan: Plan
IsCanceled: bool
RenewsAt: Instant option
}
type Tag =
| Beta
| Verified
module Tag =
let fromString = function
| "beta" -> Ok Beta
| "verified" -> Ok Verified
| str -> Error $"Tag '%s{str}' not found."
let asString = function
| Beta -> "beta"
| Verified -> "verified"
type User = {
Id: UserId
Name: string
Age: Age option
Email: Email
Profiles: ProfileId Set
Subscription: Subscription
Tags: Tag list
}Then we can just run the parser.
let user =
User.parser
|> Parser.parse json
|> Result.mapError ParserError.asString
|> Result.defaultWith failwith
printf "%s" user.NameIt can also be run asynchronously from a stream.
task {
let! result =
User.parser
|> Parser.parseAsync stream ct
let user =
result
|> Result.mapError ParserError.asString
|> Result.defaultWith failwith
return printf "%s" user.Name
}Parse.custom can be used to build parsers for third-party types or to just avoid unnecessary operations.
open Farse
module Parse =
let profileId =
Parse.custom (fun element ->
match element.TryGetGuid() with
| true, guid -> Ok <| ProfileId guid
| _ -> Error "Invalid guid." // Added as details.
) ExpectedKind.String
let instant =
Parse.custom (fun element ->
let string = element.GetString()
match InstantPattern.General.Parse(string) with
| result when result.Success -> Ok result.Value
| result -> Error result.Exception.Message // Added as details.
) ExpectedKind.StringNote: This is recommended for frequently parsed types.
ProfileId
Parser failed with 1 error[s].
Error[0]:
at $.profiles[1]
| Tried parsing 'ProfileId.
| Invalid guid.
= "927eb20f-cd62-470c-aafc-c3ce6b9248b"
Instant
Parser failed with 1 error[s].
Error[0]:
at $.subscription.renewsAt
| Tried parsing 'Instant.
| The value string does not [...]
= "202612-25T10:30:00Z"
For objects with a string discriminator.
let! x = "prop" &= oneOf "disc" [ "a", a; "b", b ]Which is equal to matching but less flexible.
let! disc = "prop.disc" &= string
let! x =
match disc with
| "a" -> "prop" &= a
| "b" -> "prop" &= b
| x -> Parser.fail $"No matching parser found for discriminator '%s{x}'."We can also try each parser in order.
let! x = "prop" &= attempt [ a; b ]There are a few different ways to validate parsed values.
let! age = "age" ?= age
let! age = "age" ?= valid byte Age.fromByte
let! age = "age" ?= verified byte (fun x -> x >= 12uy) "The minimum age is '12'."Validation can also be combined with sequences.
let! tags = "tags" &= list tag
let! tags = "tags" &= list (valid string Tag.fromString)Age
Parser failed with 1 error[s].
Error[0]:
at $.age
| Tried parsing 'Age.
| The minimum age is '12'.
= 10
Tag
Parser failed with 1 error[s].
Error[0]:
at $.tags[0]
| Tried parsing 'Tag.
| Tag 'user' not found.
= "user"
We can create JSON strings with the Json type.
open Farse
module User =
let asJson user =
JObj [
"id", JStr <| UserId.asString user.Id
"name", JStr user.Name
"age", JNum.nil Age.asByte user.Age
"email", JStr <| Email.asString user.Email
"profiles", JStr.arr ProfileId.asString user.Profiles
"subscription", JObj [
"plan", JStr <| Plan.asString user.Subscription.Plan
"isCanceled", JBit user.Subscription.IsCanceled
"renewsAt", JStr.nil _.ToString() user.Subscription.RenewsAt
]
"tags", JStr.arr Tag.asString user.Tags
]
let asJsonString = asJson >> Json.asString IndentedNote: Use JNum<'a> and JNum.nil<'a, 'b> to be explicit.
The Json type can be created from a string.
let json =
string
|> Json.fromString
|> Result.defaultWith (_.Message >> failwith)It can also be created asynchronously from a stream.
task {
let! result = Json.fromStreamAsync ct stream
return Result.defaultWith (_.Message >> failwith) result
}ParserError can be converted to a formatted string.
let msg = ParserError.asString errorBut we can also build our own error messages.
let msg =
match error with
| Json exn -> $"Parser failed: %s{exn.Message}" // Invalid JSON.
| Errors list ->
list
|> List.map (_.Path >> JsonPath.asString >> sprintf "Parser failed at: %s")
|> String.concat "\n"From the following available information.
type ParseError = {
Path: JsonPath
Element: JsonElement
Index: int option
Value: string option
Type: Type
Details: string
Exn: exn option
}Note: Farse does not throw exceptions unless something unexpected occurs.