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

Match selected parts of data

Matching checks only the values relevant to a test. Unmentioned fields and items can vary without breaking it.

Verified
open Reified
open Reified.DataDSL

let actual =
    data [
        "customer" => [
            "id" => "c-123"
            "name" => "Ada"
            "address" => [ "city" => "Adelaide"; "postcode" => 5000 ]
        ]
        "roles" => [ "author"; "billing"; "admin" ]
        "timeline" => [ "created"; "checked"; "activated" ]
        "events" => [ [ "id" => "e-1" ]; [ "id" => "e-2" ] ]
        "values" => [ 1; 2; 3 ]
        "total" => 19.95m
    ]
Requires & types
Requires: open Reified.DataDSL (packages: Reified.Data)
val actual: Data

Check selected paths

Use at and absent when only a few observations matter:

Verified
actual
|> matching [
    at "customer.name" "Ada"
    at "customer.id" anyText
    absent "error"
]
Requires & types
Requires: open Reified.DataDSL (packages: Reified.Data)

Every expectation is checked against the same root. Failures accumulate instead of stopping after the first mismatch.

The match returns unit without comparing unmentioned response fields.

For a result-returning assertion:

Verified
let result =
    Data.tryMatch [
        at "customer.name" "Grace"
        absent "customer.id"
    ] actual
Requires & types
Requires: open Reified; open Reified.DataDSL (packages: Reified.Data)
val result: Result<unit,DataMismatch list>

result is Error containing two DataMismatch values: one at customer.name with the actual text "Ada", and one at customer.id with the present identifier "c-123" that was expected to be absent.

Verified
result
// => Error [
//      { ExpectationIndex = 0; Path = DataPath.parse "customer.name"; Actual = Some (Data.Text "Ada"); ... }
//      { ExpectationIndex = 1; Path = DataPath.parse "customer.id"; Actual = Some (Data.Text "c-123"); ... }
//    ]

Match a partial object

Use containing to check related fields as one object:

Verified
actual
|> matching [
    at "customer" (
        containing [
            "name" => "Ada"
            "address" => containing [
                "city" => "Adelaide"
            ]
        ])
]
Requires & types
Requires: open Reified.DataDSL (packages: Reified.Data)

Extra fields are allowed. Required fields must be present and match their literal or recursive pattern.

This succeeds for an object such as {"name":"Ada","address":{"city":"Adelaide","postcode":5000},"id":"c-123"}. Neither postcode nor id is rejected because the pattern does not mention them at their respective object levels.

Verified
Data.tryMatch [
    at "customer" (containing [
        "name" => "Ada"
        "address" => containing [ "city" => "Adelaide" ]
    ])
] actual
// => Ok ()
Requires & types
Requires: open Reified; open Reified.DataDSL (packages: Reified.Data)

Choose list semantics explicitly

Verified
actual
|> matching [
    at "roles" (containingItems [ "admin"; "author" ])
    at "timeline" (inOrder [ "created"; "activated" ])
    at "events" (allItems (containing [ "id" => anyText ]))
    at "values" (someItem anyNumber)
]
Requires & types
Requires: open Reified.DataDSL (packages: Reified.Data)

containingItems consumes actual occurrences, so an actual item cannot satisfy two expected occurrences.

inOrder allows unrelated values between expected items. It does not reorder the actual list.

For roles = ["author","billing","admin"], containingItems [ "admin"; "author" ] succeeds regardless of order. For timeline = ["created","checked","activated"], inOrder [ "created"; "activated" ] succeeds.

containingItems [ "admin"; "admin" ] fails when admin occurs once. inOrder [ "activated"; "created" ] fails because those values occur in the opposite order.

Verified
Data.tryMatch [ at "roles" (containingItems [ "admin"; "author" ]) ] actual
// => Ok ()

Data.tryMatch [ at "roles" (containingItems [ "admin"; "admin" ]) ] actual
// => Error [ { Path = DataPath.parse "roles[1]"; Expected = "a matching list item"; ... } ]
Requires & types
Requires: open Reified; open Reified.DataDSL (packages: Reified.Data)

Use a predicate for a local rule

Verified
let positiveNumber =
    satisfying "a positive number token" (function
        | Data.Number token ->
            match System.Decimal.TryParse(token, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture) with
            | true, value -> value > 0m
            | false, _ -> false
        | _ -> false)

actual
|> matching [
    at "total" positiveNumber
]
Requires & types
Requires: open Reified; open Reified.DataDSL (packages: Reified.Data)
val positiveNumber: DataPattern

Use System.Decimal.TryParse rather than F#'s decimal conversion here: a syntactically valid JSON number token such as 1e999999 is outside decimal's range, and the conversion would throw instead of reporting a mismatch.

The description appears in mismatch output, so use wording that explains the failed requirement.

Data.Number "19.95" satisfies this predicate. Data.Number "0" produces a mismatch whose expected description is a positive number token; Data.Text "19.95" also fails because the predicate requires a number shape.

Verified
Data.tryMatch [ at "total" positiveNumber ] (data [ "total" => 19.95m ])
// => Ok ()

Data.tryMatch [ at "total" positiveNumber ] (data [ "total" => 0 ])
// => Error [ { Path = DataPath.parse "total"; Expected = "a positive number token"; ... } ]
Requires & types
Requires: open Reified; open Reified.DataDSL (packages: Reified.Data)

Complete matching vocabulary

The full pattern inventory is in the DataPattern API reference; a few representative patterns:

Verified
Data.tryMatch [
    at "customer.name" (oneOf [ exactly "Ada"; exactly "Grace" ])
    at "customer.id" anyText
    at "values" (allItems anyNumber)
] actual
// => Ok ()
Requires & types
Requires: open Reified; open Reified.DataDSL (packages: Reified.Data)

Why patterns are not constraints

anyText, anyNumber, and satisfying check Data nodes: whether a branch is text, a value is present, or a list contains an item. Constraint checks extracted typed values, such as whether a string is a valid email or an int is at least 13. Tests of produced JSON need the former.

Reified.Data has no dependencies, so test projects can use it without a validation library. Accepting a Constraint<'value> in at would add a dependency on Reified.Constraint for a test convenience.

For typed rules inside a Data tree, parse it with a schema and check the model; the schema reports paths and accumulated errors. Use satisfying for a local structural rule that no schema owns.