Decoding Guide
When you use STJ to deserialize JSON, you would likely expect to Just call
type MyType = { id: string }
let myObject = JsonSerializer.Deserialize<MyType>("""{"id":"a1b2c3d4"}""")
And call it a day, and that's fine for most of the cases specially if you own the server which is producing the JSON.
There are a few cases where you might want to customize the deserialization process, for example:
- You want to decode a JSON object into a discriminated union.
- The server is returning a JSON object with inconsistent keys, data or structure.
- The JSON deserializing process is introducing nulls in your F# code.
In these cases, you can customize the deserialization process by manually mapping the JSON to your F# types.
Automatic Decoding
Before we dive into manual decoding, let's see how you can automatically decode a JSON string into an F# type. using normal means of deserialization.
In addition to the JsonSerializer.Deserialize
method, you can use the Decoding
type provided by JDeck to decode JSON strings into F# types.
let myobj = Decoding.auto<MyType>("""{"id":"a1b2c3d4"}""")
The auto
method calls JsonDocument.Parse(jsonString)
internally and then deserializes the JSON object into the provided type. It works the same way as JsonSerializer.Deserialize
.
For cases where you want to customize the deserialization process, you can register decoders in a JsonSerializerOptions instance and pass it to the auto
method.
For the next case, let's assume that for some reason we need a special decoding process for the MyType
type.
let options = JsonSerializerOptions() |> Codec.useDecoder<MyType>(myDecoder)
let myobj2 = Decoding.auto<MyType>("""{"id":"a1b2c3d4"}""", options)
// Or
let myobj3 =
JsonSerializer.Deserialize<MyType>("""{"id":"a1b2c3d4"}""", options)
In this way, you're able to customize the deserialization process for a specific type.
Speaking of decoders, a decoder is defined as:
type Decoder<'TResult> = JsonElement -> Result<'TResult, DecodeError>
Where JsonElement
is the type representing a JSON object in STJ.
where 'TResult
is the type you want to decode the JSON into. For example a Decoder<int>
would map a JSON string to an integer.
Required Vs Optional
A Decoder can be defined as required or optional. A required decoder will fail if the data does not match the expected type, and if it is missing from the JSON string, while an optional decoder will not fail if the data is missing or has a null value.
As an example let's see the following two values.
let value = Decoding.fromString("10", Required.int)
printfn $"Value: %A{value}" // Value: Ok 10
let noValue = Decoding.fromString("null", Optional.int)
printfn $"Value: %A{noValue}" // Value: Ok None
As you see, the value is successful and the noValue
is successful as well, but it is None
. given that null is not a valid integer in F#.
However, if you try to decode a value that is not an integer with an optional integer decoder, it will fail.
let invalidValue = Decoding.fromString("\"abc\"", Optional.int)
printfn $"Value: %A{invalidValue}"
// Error { value = abc
// kind = String
// rawValue = "\"abc\""
// targetType = System.Object
// message = "Expected 'Number' but got `String`"
// exn = None
// index = None
// property = None }
With this you can be sure that the data you are decoding is of the expected type, even if it is missing or not.
Decoding JSON Objects
As you're already aware, decoding primitives returns results, and this means that in order to decode a JSON object you need to do it only on successful results.
Thus needing to nest and generate pyramids of match
expressions which is not ideal not funny, unmaintainable and cumbersome.
Note: In general we recommend that you use FsToolkit.ErrorHandling's
result {}
computation expression to handle the errors and the results of the decoders. In a very seamless way. Please refer to the FsToolkit Section for more information.
For cases like those and if you want to avoid the dependency on FsToolkit.ErrorHandling, you can use the built-in decode {}
computation expression. we provide.
The decode {}
computation expression is a way to chain multiple decoders together in a single expression, and it will short-circuit if any of the decoders fail.
type Person = {
name: string
age: int
email: string option
}
let objectDecoder: Decoder<Person> =
fun jsonElement -> decode {
let! name = Required.Property.get ("name", Required.string) jsonElement
and! age = Required.Property.get ("age", Required.int) jsonElement
// An optional property means that the key "emails" can be missing from the JSON object
// However, if the key is present it must comply with the decoder (in this case a string)
and! email = Optional.Property.get ("emails", Required.string) jsonElement
return {
name = name
age = age
email = email
}
}
Another way to decode the above object is to expect the email key to be present in the document, but it can be null.
let objDecoder2 jsonElement = decode {
let! name = Required.Property.get ("name", Required.string) jsonElement
and! age = Required.Property.get ("age", Required.int) jsonElement
// Now the key must be present in the JSON object, but it can be null.
and! email = Required.Property.get ("emails", Optional.string) jsonElement
return {
name = name
age = age
email = email
}
}
Decoding Discriminated Unions
Decoding a discriminated union is a bit complex as it may be represented in many shapes or forms, there isn't really a general concensus on how to represent a discriminated union in JSON and that's the reason it is not supported by default in STJ. Let's define the following discriminated union.
type PageStatus =
| Idle
| Loading
| FailedWith of string
| Special of int
How do you represent this in JSON? a string or an array with a string and a value? or an object with a key and a value? however you decide to represent it, you need to write a decoder for it.
For cases like this, we provide a helper function called oneOf
which takes a list of decoders and tries to decode the JSON object with each decoder until one of them succeeds.
First let's define the decoders for the PageStatus
type.
module PageStatus =
// decodes {"status": "idle"}
let idleAndLoadingDecoder el = decode {
let! value = Required.string el
match value with
| "idle" -> return Idle
| "loading" -> return Loading
| _ ->
return!
DecodeError.ofError(
el.Clone(),
"The provided value is not either idle or loading"
)
|> Result.Error
}
// decodes {"status": ["failed-with", "message"] }
// decodes {"status": ["special", <int status>] }
let failedOrSpecialDecoder el = decode {
return!
el
|> Required.Property.get(
"status",
fun el -> decode {
let! type' = Decode.decodeAt Required.string 0 el
match type' with
| "failed-with" ->
return!
Decode.decodeAt Required.string 1 el |> Result.map FailedWith
| "special" ->
return! Decode.decodeAt Required.int 1 el |> Result.map Special
| _ ->
return!
DecodeError.ofError(
el,
"The provided value is not either \"failed-with\" or \"special\""
)
|> Error
}
)
}
Note: Don't forget to call
.Clone()
on theJsonElement
when you're returning an error, as the JsonElement may come from a JsonDocument which is a disposable type. If you don't clone it, you run the risk of the JsonElement being disposed before you can use it.
As you can see, the idleAndLoadingDecoder
decoder expects a JSON object with a key status
and a value of either idle
or loading
,
while the failedDecoder
expects a JSON object with a key status
and a value of an array with two elements,
the first element is failed-with
and the second element is the message.
Now let's use the oneOf
function to decode the JSON object.
let decodedValue =
Decoding.fromString(
"""{"status": "idle"}""",
Decode.oneOf [
PageStatus.failedOrSpecialDecoder
PageStatus.idleAndLoadingDecoder
]
)
// val decodedValue : Result<PageStatus, DecodeError> = Ok Idle
While the order of the decoders in the oneOf
is not important,
since we're in a stop-on-first-success mode,
it is recommended to put the most likely decoders to succeed first.
val string: value: 'T -> string
--------------------
type string = System.String
<summary>Provides functionality to serialize objects or value types to JSON and to deserialize JSON into objects or value types.</summary>
(+0 other overloads)
JsonSerializer.Deserialize<'TValue>(reader: byref<Utf8JsonReader>, ?options: JsonSerializerOptions) : 'TValue
(+0 other overloads)
JsonSerializer.Deserialize<'TValue>(node: Nodes.JsonNode, jsonTypeInfo: Metadata.JsonTypeInfo<'TValue>) : 'TValue
(+0 other overloads)
JsonSerializer.Deserialize<'TValue>(node: Nodes.JsonNode, ?options: JsonSerializerOptions) : 'TValue
(+0 other overloads)
JsonSerializer.Deserialize<'TValue>(element: JsonElement, jsonTypeInfo: Metadata.JsonTypeInfo<'TValue>) : 'TValue
(+0 other overloads)
JsonSerializer.Deserialize<'TValue>(element: JsonElement, ?options: JsonSerializerOptions) : 'TValue
(+0 other overloads)
JsonSerializer.Deserialize<'TValue>(document: JsonDocument, jsonTypeInfo: Metadata.JsonTypeInfo<'TValue>) : 'TValue
(+0 other overloads)
JsonSerializer.Deserialize<'TValue>(document: JsonDocument, ?options: JsonSerializerOptions) : 'TValue
(+0 other overloads)
JsonSerializer.Deserialize<'TValue>(json: string, jsonTypeInfo: Metadata.JsonTypeInfo<'TValue>) : 'TValue
(+0 other overloads)
JsonSerializer.Deserialize<'TValue>(json: string, ?options: JsonSerializerOptions) : 'TValue
(+0 other overloads)
static member Decoding.auto: json: byte array * ?docOptions: JsonDocumentOptions -> Result<'TResult,DecodeError>
static member Decoding.auto: json: string * ?docOptions: JsonDocumentOptions -> Result<'TResult,DecodeError>
static member Decoding.auto: json: System.IO.Stream * options: JsonSerializerOptions * ?docOptions: JsonDocumentOptions -> System.Threading.Tasks.Task<Result<'TResult,DecodeError>>
static member Decoding.auto: json: byte array * options: JsonSerializerOptions * ?docOptions: JsonDocumentOptions -> Result<'TResult,DecodeError>
static member Decoding.auto: json: string * options: JsonSerializerOptions * ?docOptions: JsonDocumentOptions -> Result<'TResult,DecodeError>
<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> 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>)
type JsonSerializerOptions = new: unit -> unit + 2 overloads member AddContext<'TContext (requires default constructor and 'TContext :> JsonSerializerContext)> : unit -> unit member GetConverter: typeToConvert: Type -> JsonConverter member GetTypeInfo: ``type`` : Type -> JsonTypeInfo member MakeReadOnly: unit -> unit + 1 overload member TryGetTypeInfo: ``type`` : Type * typeInfo: byref<JsonTypeInfo> -> bool member AllowOutOfOrderMetadataProperties: bool member AllowTrailingCommas: bool member Converters: IList<JsonConverter> member DefaultBufferSize: int ...
<summary>Provides options to be used with <see cref="T:System.Text.Json.JsonSerializer" />.</summary>
--------------------
JsonSerializerOptions() : JsonSerializerOptions
JsonSerializerOptions(defaults: JsonSerializerDefaults) : JsonSerializerOptions
JsonSerializerOptions(options: JsonSerializerOptions) : JsonSerializerOptions
<summary>Represents a specific JSON value within a <see cref="T:System.Text.Json.JsonDocument" />.</summary>
module Result from Microsoft.FSharp.Core
--------------------
[<Struct>] type Result<'T,'TError> = | Ok of ResultValue: 'T | Error of ErrorValue: 'TError
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>
static member Decoding.fromString: value: string * options: JsonDocumentOptions * decoder: Decoder<'TResult> -> Result<'TResult,DecodeError>
<summary> Contains a set of decoders that are not required to decode to the particular type and will not fail. These decoders will return an option type. even if the value is null or is absent from the JSON element. </summary>
val int: value: 'T -> int (requires member op_Explicit)
--------------------
type int = int32
--------------------
type int<'Measure> = int
<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 or is null in the JSON element, the decoding will return an option type. </remarks>
static member Optional.Property.get: name: string * decoder: Decoder<'TResult> -> (JsonElement -> Result<'TResult option,DecodeError>)
module Decode from JDeck.Decode
--------------------
module Decode from JDeck
<summary> Attempts to decode a JSON element that is living inside an array at the given index. </summary>
<param name="decoder"></param>
<param name="index"></param>
<param name="el"></param>
<summary> Takes a list of possible decoders and tries to decode the JSON element with each one of them. </summary>
<remarks> This is useful to decode JSON elements into discriminated unions </remarks>
<param name="decoders"></param>
module PageStatus from Decoding
--------------------
type PageStatus = | Idle | Loading | FailedWith of string | Special of int