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

Parsing

Maturity: Incubating

Reified.Parse converts serialized text into primitive typed values. "42" becomes 42, "true" becomes true. Unlike Int32.TryParse, it returns a value identifying which of three failures occurred.

dotnet add package Reified.Parse
Verified
open Reified

Parse.int "12"     // Ok 12
Parse.int ""       // Error (MissingValue "int")
Parse.int "twelve" // Error (InvalidFormat ("int", "twelve"))
Parse.int "99999999999" // Error (OutOfRange ("int", "99999999999"))

ParseError is an independent leaf type. It carries no prose and no culture, so it stays comparable data you can map into your own error case, assert on in a test, or pass across a boundary.

The three cases

Verified
type ParseError =
    | MissingValue of target: string
    | InvalidFormat of target: string * input: string
    | OutOfRange of target: string * input: string
Case What it means Example
MissingValue target The input was absent, empty, or only whitespace. There was nothing to convert. Parse.int " " → MissingValue "int"
InvalidFormat (target, input) Text was supplied but does not spell a value of that type. Parse.bool "yes" → InvalidFormat ("bool", "yes")
OutOfRange (target, input) Text spells a well-formed number the destination type cannot hold. Parse.int "99999999999" → OutOfRange ("int", "99999999999")

target names the destination type, "int", "bool", "decimal", "Guid", so a caller can tell which conversion failed without tracking it separately. input is the offending text, retained for redisplay.

An empty field and a misspelled one can need different messages. OutOfRange identifies a well-formed number outside the destination type's range, either too large or too small: Parse.int "-2147483649" reports OutOfRange ("int", "-2147483649").

The parsers

The common parsers:

Parser Returns Accepts
Parse.int int Optionally signed digits, invariant culture.
Parse.decimal decimal Digits with an optional sign, decimal point, and thousands separators, invariant culture.
Parse.bool bool "true" or "false", any casing. Not "1", "yes", or "on".
Parse.guid System.Guid Any format Guid.TryParse accepts, including braced and hyphenless.
Parse.enum<'enum> 'enum A case name, case-insensitive, or its numeric text.

The complete table — long, float, dateTime, dateTimeOffset, dateOnly, timeOnly, and the exact accepted form of every parser — is in the Parse API reference.

Every parser takes string and returns Result<'value, ParseError>. Numeric parsers use invariant culture: the same text parses the same way on every machine, and the decimal point is ., not ,. They also ignore leading and trailing whitespace; whitespace-only input is still MissingValue.

Only Parse.int, Parse.long, and Parse.decimal can produce OutOfRange; every other parser reports MissingValue or InvalidFormat. Parse.float never reports OutOfRange: input beyond float's range parses as infinity or -infinity, and input too close to zero parses as 0.0.

The three failures in practice

Verified
// Missing: nothing was supplied.
Parse.decimal ""
// Error (MissingValue "decimal")

// Malformed: something was supplied, but it is not a decimal.
Parse.decimal "19,95 AUD"
// Error (InvalidFormat ("decimal", "19,95 AUD"))

// Out of range: well-formed, but outside the type's range.
Parse.int "2147483648"
// Error (OutOfRange ("int", "2147483648"))

Match on the case when the three need different treatment:

Verified
let describe error =
    match error with
    | MissingValue target -> $"Please supply a {target}."
    | InvalidFormat (_, input) -> $"'{input}' is not in the expected format."
    | OutOfRange (_, input) -> $"'{input}' is out of range."

Hand off to your own error type

Map ParseError into an application error at the point of use when callers do not need the parse details:

Verified
type PortError = PortNotANumber of string

let port raw : Result<int, PortError> =
    Parse.int raw |> Result.mapError (fun _ -> PortNotANumber raw)

Result.orError from Reified.Result is shorter when the error case carries nothing:

Verified
type MissingPort = PortMissing

Parse.int "abc" |> Result.orError PortMissing

When callers need to distinguish the three failures or redisplay the text, use Result.mapError InvalidInteger to keep ParseError in an application error case.

For structured input, Schema parses each field, accumulates every failure, and reports the field path alongside it.

Optional input

Parse.optional distinguishes an absent value from a bad one. Absence succeeds as None; malformed present text still fails.

Verified
Parse.optional Parse.int None
// Ok None

Parse.optional Parse.int (Some "42")
// Ok (Some 42)

Parse.optional Parse.int (Some "bad")
// Error (InvalidFormat ("int", "bad"))

Parse.optionalOr supplies a fallback only when the input is absent:

Verified
Parse.optionalOr 80 Parse.int None
// Ok 80

Parse.optionalOr 80 Parse.int (Some "443")
// Ok 443

Parse.optionalOr 80 Parse.int (Some "bad")
// Error (InvalidFormat ("int", "bad"))

The fallback applies to omitted input, not malformed input.

A present-but-blank string is a present input, so it parses (and fails) rather than taking the fallback. The primitive parsers treat empty or whitespace-only text as a missing value, not a format error:

Verified
Parse.optionalOr 80 Parse.int (Some "")
// Error (MissingValue "int")

Parse.optionalOr 80 Parse.int (Some "   ")
// Error (MissingValue "int")

Some "" and Some " " produce MissingValue, never Ok 80. Only None receives the fallback.

Combined optional helpers

Named helpers pair the common primitive parsers with optional and optionalOr; each preserves the error from malformed present text:

Family Absence Default
Parse.int intOption → Ok None intOrDefault value → Ok value
Parse.bool boolOption → Ok None boolOrDefault value → Ok value
Parse.decimal decimalOption → Ok None decimalOrDefault value → Ok value
Parse.guid guidOption → Ok None guidOrDefault value → Ok value

Use *Option when absence should stay None, and *OrDefault when absence should become a concrete value.

Parse, then refine

Parsing changes representation; a refinement then admits the typed value into a domain type by checking a constraint. Here Parse.int converts text to int, and Refinement.create positiveInt admits it as a PositiveInt. Each step contributes its own error:

Verified
open Reified
open Reified.Refinements
open Reified.Result
open Reified.ResultDSL

type PositiveInt = PositiveInt of int

let positiveInt =
    Refinement.define (Constraint.greaterThan 0) PositiveInt (fun (PositiveInt value) -> value)

type QuantityError =
    | InvalidInteger of ParseError
    | InvalidQuantity of Violation

let quantity raw =
    result {
        let! parsed = Parse.int raw |> Result.mapError InvalidInteger
        let! quantity = parsed |> Refinement.create positiveInt |> Result.mapError InvalidQuantity
        return quantity
    }

See Refined values for the refinement model, and the Parse API reference for every parser.