Using with FsToolkit.ErrorHandling
One of the most popular libraries for workflows in F# is [FsToolkit.ErrorHandling], this library provides several computation expressions to handle errors in a functional way.
JDeck uses the Result
type to handle errors, so it's easy to integrate with FsToolkit.ErrorHandling, Our decode {}
computation expression is basically just a result CE in disguise and constrained to a particular error type.
It works for our purposes when you don't want the dependency on the library however, our CE is severely limitated to our particular use case.
If you're already using FsToolkit in your codebase we recommend using the result {}
or the validation {}
CE instead.
let jDeckDecoder =
fun el -> decode { // built-in CE
let! value = Required.int el
return {| value = value |}
}
let fsToolkitDecoder =
fun el -> result { // FsToolkit CE
let! value = Required.int el
return {| value = value |}
}
let fsToolkitValDecoder =
fun el -> validation { // FsToolkit CE
let! value = Required.int el
return {| value = value |}
}
As you can see, the above decoders are completely equivalent with the exception of the validation one, this returns a list of errors instead of a single error. Given that the result CE is a drop-in replacement for the decode CE, from now on we'll focus on the validation CE.
Validations
For cases where you'd like to keep decoding after an error for further recollection, this is the way to go.
You may have seen some functions and methods with col
or collect
in their names, these are meant to be used with the validation {}
CE.
// For example let's say we want to decode the payload of a posted user to our server
type User = { username: string; emails: string seq }
First we define some rules for our validations, in the case of the username we want to be sure that it is not an empty string, and it is within a certain limit of characters. These validations here are arbitrary but you should be able to see how can you enforce your own domain rules when decoding the json objects
let usernameRules (value: string) (el: JsonElement) = validation {
let! _ =
value
|> Result.requireNotEmpty "Name cannot be empty"
|> Result.mapError(fun msg -> DecodeError.ofError(el.Clone(), msg))
and! _ =
value.Length >= 6
|> Result.requireTrue "username has to be at last 6 characters"
|> Result.mapError(fun msg -> DecodeError.ofError(el.Clone(), msg))
and! _ =
value.Length <= 20
|> Result.requireTrue "username has to be at most 20 characters"
|> Result.mapError(fun msg -> DecodeError.ofError(el.Clone(), msg))
return ()
}
In the case of the emails, we will do very simple validations but as you may imagine, you can validate domains, against a regex, and even if it already exists if you pass the correct information to this validation.
let emailRules (index: int, value: string) (el: JsonElement) = result {
let! _ =
value.Contains("@")
|> Result.requireTrue $"Element at {index} - must contain @"
|> Result.mapError(fun msg -> DecodeError.ofIndexed(el.Clone(), index, msg))
and! _ =
value.Contains(".")
|> Result.requireTrue $"Element at {index} - must contain ."
|> Result.mapError(fun msg -> DecodeError.ofIndexed(el.Clone(), index, msg))
return ()
}
Then we can decode the payload and apply the validations Keep in mind that we're using strings for simplicity here but the errors should match the actual error of your domain types
let bindJson (reqBody: string) = validation {
use document = JsonDocument.Parse(reqBody)
let json = document.RootElement
let! username =
let decoder =
fun el -> validation {
let! value = Required.string el
do! usernameRules value el
return value
}
Required.Property.get ("username", decoder) json
let! emails =
// for validation to work we need to wrap the decoders in a validation {} CE
// this is because we can't define overloads based on the return type.
let decoder =
fun (index: int) el -> validation {
// decode the element as a string
let! email = Required.string el
// validate that it is a valid email according to our rules
do! emailRules (index, email) el
// return a validated email
return email
}
json |> Required.Property.get("emails", Decode.sequenceCol(decoder))
return { username = username; emails = emails }
}
When we apply this decoder which is also validating our rules to the following JSON string, we expect it to fail, but rather than telling us a single error it will collect the ones available and report them together.
// { "username": "John Doe", "emails": ["email1@email.com", null, "email2email.com", "not-an-email", null] }
match bindJson reqBody with
| Ok user -> printfn "User: %A" user
| Error errors -> printfn "Errors: %A" (errors |> List.map _.message)
// Errors: [
// "Expected 'String' but got `Null`"
// "Element at 2 - must contain @"
// "Element at 3 - must contain @"
// "Expected 'String' but got `Null`"
// ]
If we provide a non-null string list then we're able to see just the errors that correspond to our validations
// { "username": "John Doe", "emails": ["email1@email.com", "email2@email.com", "not-an-email", "email4@emailcom] }
match bindJson reqBody2 with
| Ok user -> printfn "User: %A" user
| Error errors -> printfn "Errors: %A" (errors |> List.map _.message)
// Errors: ["Element at 2 - must contain @"; "Element at 3 - must contain ."]
Note: Sometimes for simplicity, folks use strings as the resulting error, it is recommended that you provide a more meaningful and information rich type for your errors, as these will be passed on potentially over several layers and the information could be lost if you don't provide a proper type.
<summary> Computation expression to seamlessly decode JSON elements. </summary>
<example><code lang="fsharp"> type Person = { Name: string; Age: int } let PersonDecoder = decode { let! name = Property.get "name" Decode.Required.string and! age = Property.get "age" Decode.Required.int return { Name = name; Age = age } } </code></example>
<summary> Contains a set of decoders that are required to decode to the particular type otherwise the decoding will fail. </summary>
<summary> The <c>Result</c> computation expression. </summary>
val string: value: 'T -> string
--------------------
type string = String
val seq: sequence: 'T seq -> 'T seq
--------------------
type 'T seq = Collections.Generic.IEnumerable<'T>
<summary>Represents a specific JSON value within a <see cref="T:System.Text.Json.JsonDocument" />.</summary>
module Result from FsToolkit.ErrorHandling
<summary> Helper functions for working with <c>Result</c> values. </summary>
--------------------
module Result from Microsoft.FSharp.Core
--------------------
[<Struct>] type Result<'T,'TError> = | Ok of ResultValue: 'T | Error of ErrorValue: 'TError
<summary> Returns <c>Ok</c> if the sequence is not empty, or the specified error if it is. Documentation is found here: <href>https://demystifyfp.gitbook.io/fstoolkit-errorhandling/fstoolkit.errorhandling/result/requirefunctions#requirenotempty</href></summary>
<param name="error">The error value to return if the sequence is empty.</param>
<param name="xs">The sequence to check.</param>
<returns>An <c>Ok</c> result if the sequence is not empty, otherwise an <c>Error</c> result with the specified error value.</returns>
val mapError: [<InlineIfLambda>] errorMapper: ('errorInput -> 'errorOutput) -> input: Result<'ok,'errorInput> -> Result<'ok,'errorOutput>
<summary> Maps the error value of a <c>Result</c>to a new error value using the specified error mapper function. Documentation is found here: <href>https://demystifyfp.gitbook.io/fstoolkit-errorhandling/fstoolkit.errorhandling/result/maperror</href></summary>
<param name="errorMapper">The function that maps the input error value to the output error value.</param>
<param name="input">The <c>Result</c>value to map the error value of.</param>
<returns>A new <c>Result</c>with the same Ok value and the mapped error value.</returns>
--------------------
val mapError: mapping: ('TError -> 'U) -> result: Result<'T,'TError> -> Result<'T,'U>
module DecodeError from JDeck
--------------------
type DecodeError = { value: JsonElement kind: JsonValueKind rawValue: string targetType: Type message: string exn: exn option index: int option property: string option } member Equals: DecodeError * IEqualityComparer -> bool
<summary> In case of failure when a type is being decoded, this type is meant to contain the relevant information about the error. </summary>
<summary> Requires a boolean value to be true, otherwise returns an error result. Documentation is found here: <href>https://demystifyfp.gitbook.io/fstoolkit-errorhandling/fstoolkit.errorhandling/result/requirefunctions#requiretrue</href></summary>
<param name="error">The error value to return if the condition is false.</param>
<param name="value">The boolean value to check.</param>
<returns>An <c>Ok</c> result if the condition is true, otherwise an Error result with the specified error value.</returns>
val int: value: 'T -> int (requires member op_Explicit)
--------------------
type int = int32
--------------------
type int<'Measure> = int
String.Contains(value: char) : bool
String.Contains(value: string, comparisonType: StringComparison) : bool
String.Contains(value: char, comparisonType: StringComparison) : bool
<summary>Provides a mechanism for examining the structural content of a JSON value without automatically instantiating data values.</summary>
JsonDocument.Parse(json: ReadOnlyMemory<char>, ?options: JsonDocumentOptions) : JsonDocument
JsonDocument.Parse(utf8Json: ReadOnlyMemory<byte>, ?options: JsonDocumentOptions) : JsonDocument
JsonDocument.Parse(utf8Json: Stream, ?options: JsonDocumentOptions) : JsonDocument
JsonDocument.Parse(utf8Json: Buffers.ReadOnlySequence<byte>, ?options: JsonDocumentOptions) : JsonDocument
<summary>Gets the root element of this JSON document.</summary>
<returns>A <see cref="T:System.Text.Json.JsonElement" /> representing the value of the document.</returns>
<summary> This type containes methods that are particularly useful to decode properties from JSON elements. They can be primitive properties, objects, arrays, etc. </summary>
<remarks> If the property is not found in the JSON element, the decoding will fail. </remarks>
static member Required.Property.get: name: string * decoder: Decoder<'TResult> -> (JsonElement -> Result<'TResult,DecodeError>)
module Decode from JDeck.Decode
--------------------
module Decode from JDeck
<summary> Decodes a JSON array element into the value of type <typeparamref name="TResult" />. </summary>
<remarks> If a failure is encountered in the decoding process, the error is collected and the decoding continues however, the result will be an error containing a list of all the errors that occurred during the decoding process. </remarks>
<param name="decoder"></param>
<param name="el"></param>
module List from FsToolkit.ErrorHandling
--------------------
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 member IsEmpty: bool member Item: index: int -> 'T with get ...