Repository F# setup
open Reified
open Reified.Refinements
open Reified.Result

Build, vary, and test structured data

Build a customer value, derive requests, parse a JSON response, and check selected fields.

Create the baseline

Open Reified.Data for the Data type and module. Open Reified.DataDSL for the concise literal, edit, and matching syntax:

Verified
open Reified
open Reified.DataDSL

let customer =
    data [
        "name" => "Ada"
        "plan" => "free"
        "nickname" ?=> (None: string option)
        "deletedAt" => nil

        "address" => [
            "city" => "Adelaide"
            "postcode" => 5000
        ]

        "roles" => [ "author" ]
    ]
Requires & types
Requires: open Reified.DataDSL (packages: Reified.Data)
val customer: Data

Nested object fields use the same list syntax. ?=> None omits nickname; nil keeps deletedAt as a present null.

Render it with Data.render:

Verified
Data.render customer
// => "{ name: \"Ada\", plan: \"free\", deletedAt: null, address: { city: \"Adelaide\", postcode: 5000 }, roles: [\"author\"] }"
Requires & types
Requires: open Reified (packages: Reified.Data)

Numbers follow these rules:

  • int and int64 become base-10 digits, such as 5000 and -12.
  • decimal always uses . as its decimal separator, regardless of the machine's locale.
  • float uses enough digits to read back as the same finite value. NaN and infinity are rejected because JSON cannot represent them.
  • num validates and keeps the token exactly as written. Use it when spelling matters, such as 1.2300e+4.
Verified
let invoice =
    data [
        "amount" => 19.95m
        "measurement" => num "1.2300e+4"
    ]
Requires & types
Requires: open Reified.DataDSL (packages: Reified.Data)
val invoice: Data

invoice contains Data.Number "19.95" for amount and the unchanged token Data.Number "1.2300e+4" for measurement.

Verified
Data.lookupPath "amount" invoice
// => Data.Number "19.95"

Data.lookupPath "measurement" invoice
// => Data.Number "1.2300e+4"
Requires & types
Requires: open Reified (packages: Reified.Data)

Derive a request

Apply strict edits instead of reconstructing the fixture:

Verified
let upgradeRequest =
    customer
    |> Data.patch [
        replace "plan" "pro"
        append "roles" "admin"
        remove "deletedAt"
    ]
Requires & types
Requires: open Reified; open Reified.DataDSL (packages: Reified.Data)
val upgradeRequest: Data

replace requires its target to exist. set can instead add a missing final object field. Edits run in order and the complete patch is atomic.

Use Data.tryPatch when edits came from dynamic input and should return structured failures.

Render the changed request with Data.render:

Verified
Data.render upgradeRequest
// => "{ name: \"Ada\", plan: \"pro\", address: { city: \"Adelaide\", postcode: 5000 }, roles: [\"author\", \"admin\"] }"
Requires & types
Requires: open Reified (packages: Reified.Data)

The original customer still contains plan: "free", one role, and the deletedAt field.

Derive named cases

Verified
let nameCases =
    customer
    |> variants [
        variant "present" []
        variant "missing" [ remove "name" ]
        variant "blank" [ replace "name" "" ]
        variant "wrong shape" [ replace "name" [ "Ada" ] ]
    ]
Requires & types
Requires: open Reified.DataDSL (packages: Reified.Data)
val nameCases: DataCase list

Each DataCase is a record of Name and the materialized Value, so a failing test can report which case it was. Declaration order is preserved. See Build variations and matrices for the case types.

The name field of each case is, in order, "Ada", absent, "", and Data.List [ Data.Text "Ada" ].

Verified
nameCases |> List.map (fun case -> case.Name, Data.tryFindPath "name" case.Value)
// =>
// [ ("present", Some (Data.Text "Ada"))
//   ("missing", None)
//   ("blank", Some (Data.Text ""))
//   ("wrong shape", Some (Data.List [ Data.Text "Ada" ])) ]
Requires & types
Requires: open Reified (packages: Reified.Data)

Parse JSON output

Json.parseData lives in Reified.Schema, not in the Data-only install shown at the top of this section. Add the package for this step:

dotnet add package Reified.Schema
Verified
let response =
    Reified.Json.parseData
        """{
          "customer": {
            "id": "c-123",
            "name": "Ada",
            "plan": "pro",
            "roles": ["author", "admin"]
          }
        }"""
Requires & types
Requires: (packages: Reified.Schema)
val response: Data

The parser is portable across .NET and Fable and returns a fully owned Data tree.

Data.lookupPath "customer.id" response returns Data.Text "c-123". Rendering the response produces a stable JSON value with the same field order and number tokens as the parsed tree.

Verified
Data.lookupPath "customer.id" response
// => Data.Text "c-123"
Requires & types
Requires: open Reified (packages: Reified.Data)

Check the behavior

Use paths for individual checks and containing when several checks belong to the same object:

Verified
response
|> matching [
    at "customer.id" anyText

    at "customer" (
        containing [
            "name" => "Ada"
            "plan" => "pro"
            "roles" => containingItems [ "admin" ]
        ])

    absent "error"
]
Requires & types
Requires: open Reified.DataDSL (packages: Reified.Data)

Unmentioned object fields are allowed by containing. Literal values inside the pattern remain exact.

matching raises DataMatchException when a test fails. Use Data.tryMatch when the mismatches should be returned as a value.

The match returns unit despite the unmentioned id field and additional author role.

Changing the expected plan to "free" produces a mismatch at customer.plan. Adding absent "customer.id" produces another mismatch because that path contains Data.Text "c-123".

Verified
Data.tryMatch [ at "customer.plan" "free"; absent "customer.id" ] response
// => Error [
//      { ExpectationIndex = 0; Path = DataPath.parse "customer.plan"; Actual = Some (Data.Text "pro"); ... }
//      { ExpectationIndex = 1; Path = DataPath.parse "customer.id"; Actual = Some (Data.Text "c-123"); ... }
//    ]
Requires & types
Requires: open Reified; open Reified.DataDSL (packages: Reified.Data)

Compare the complete result

Use exact comparison when every field is part of the contract:

Verified
let expected =
    data [ "customer" => [ "plan" => "free" ] ]

match Data.compare expected response with
| Ok () -> ()
| Error differences ->
    differences
    |> List.iter (fun difference ->
        printfn "%s" (DataPath.toString difference.Path))
Requires & types
Requires: open Reified; open Reified.DataDSL (packages: Reified.Data)
val expected: Data

Data.diff returns the same differences directly. Exact comparison observes number tokens, object order, duplicate fields, list length, and list order.

For equal trees, Data.compare returns Ok (). In the match above, expected supplies plan "free" while response supplies plan "pro", so Data.compare expected response reports a DataDifference at customer.plan with expected value Data.Text "free" and actual value Data.Text "pro". Swapping the two arguments reports the same path with the expected and actual values reversed:

Verified
Data.compare
    (data [ "customer" => [ "plan" => "pro" ] ])
    (data [ "customer" => [ "plan" => "free" ] ])
// => Error [
//      { Path = DataPath.parse "customer.plan"
//        Expected = Some (Data.Text "pro")
//        Actual = Some (Data.Text "free")
//        Cause = DataDifferenceCause.DifferentValue }
//    ]
Requires & types
Requires: open Reified; open Reified.DataDSL (packages: Reified.Data)