This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Guides

Comprehensive guides for using Morphir .NET

Generate model code with the Myriad F# reference, then follow the car rental tutorial through a complete rental and invoice.

Explore our guides to learn how to use Morphir .NET effectively.

Tutorials

Available Guides

  • Configuration - Current support and the planned shared configuration/project system
  • IR Modeling - Learn how to model Morphir IR in .NET
  • Serialization - Working with JSON serialization
  • Testing - Testing strategies and best practices

Best Practices

  • Immutability First: Always prefer immutable data structures
  • ADT Design: Use algebraic data types to make illegal states unrepresentable
  • Type Safety: Leverage C# 14 features for strong typing
  • Testing: Write comprehensive tests using TUnit and Reqnroll

1 - IR Modeling

Learn how to model Morphir IR in .NET

Overview

Morphir IR (Intermediate Representation) is the core data structure that represents your business logic. In Morphir .NET, we model the IR using C# record types and algebraic data types (ADTs).

Type Expressions

Type expressions represent the types in your Morphir model:

public abstract record TypeExpr
{
    public sealed record TInt() : TypeExpr;
    public sealed record TString() : TypeExpr;
    public sealed record TBool() : TypeExpr;
    public sealed record TTuple(IReadOnlyList<TypeExpr> Items) : TypeExpr;
    public sealed record TRecord(IReadOnlyDictionary<string, TypeExpr> Fields) : TypeExpr;
    public sealed record TFunc(TypeExpr Input, TypeExpr Output) : TypeExpr;
}

Value Expressions

Value expressions represent the actual values and computations:

public abstract record ValueExpr
{
    public sealed record Literal(LiteralValue Value) : ValueExpr;
    public sealed record Variable(string Name) : ValueExpr;
    public sealed record Lambda(string Parameter, TypeExpr ParameterType, ValueExpr Body) : ValueExpr;
    public sealed record Apply(ValueExpr Function, ValueExpr Argument) : ValueExpr;
}

Best Practices

  1. Use Records: Prefer record types for immutable data structures
  2. Pattern Matching: Use exhaustive pattern matching for ADTs
  3. Validation: Implement smart constructors for validated types
  4. Immutability: Keep all types immutable

Example

Here’s a complete example of modeling a simple function:

var addFunction = new ValueExpr.Lambda(
    Parameter: "x",
    ParameterType: new TypeExpr.TInt(),
    Body: new ValueExpr.Lambda(
        Parameter: "y",
        ParameterType: new TypeExpr.TInt(),
        Body: new ValueExpr.Apply(
            Function: new ValueExpr.Variable("+"),
            Argument: new ValueExpr.Tuple(new[]
            {
                new ValueExpr.Variable("x"),
                new ValueExpr.Variable("y")
            })
        )
    )
);

2 - Serialization

Working with JSON serialization in Morphir .NET

Overview

Morphir .NET provides JSON serialization support for Morphir IR, enabling interoperability with other Morphir tooling.

Basic Usage

Serializing IR to JSON

using Morphir.Core.IR;
using System.Text.Json;

var typeExpr = new TypeExpr.TInt();
var json = JsonSerializer.Serialize(typeExpr, new JsonSerializerOptions
{
    WriteIndented = true
});

Deserializing JSON to IR

var json = @"{""_tag"": ""TInt""}";
var typeExpr = JsonSerializer.Deserialize<TypeExpr>(json);

JSON Format

Morphir IR uses a tagged union format in JSON:

{
  "_tag": "TTuple",
  "items": [
    { "_tag": "TInt" },
    { "_tag": "TString" }
  ]
}

Custom Serialization

For custom serialization needs, you can implement your own converters:

public class CustomTypeExprConverter : JsonConverter<TypeExpr>
{
    // Implementation
}

Roundtrip Testing

Always test roundtrip serialization to ensure compatibility:

var original = new TypeExpr.TInt();
var json = JsonSerializer.Serialize(original);
var deserialized = JsonSerializer.Deserialize<TypeExpr>(json);
Assert.Equal(original, deserialized);

3 - Testing

Testing strategies and best practices for Morphir .NET

Overview

Morphir .NET supports multiple testing approaches to ensure code quality and correctness.

Unit Testing with TUnit

TUnit is the primary unit testing framework:

using TUnit.Assertions;
using TUnit.Core;

public class TypeExprTests
{
    [Test]
    public void TInt_Should_Be_Equal()
    {
        var type1 = new TypeExpr.TInt();
        var type2 = new TypeExpr.TInt();
        
        Assert.That(type1).IsEqualTo(type2);
    }
}

Behavior-Driven Development with Reqnroll

Reqnroll enables BDD-style testing:

Feature: Type Expression Creation
  Scenario: Create an integer type
    Given I want to create a type expression
    When I create a TInt
    Then it should be a valid type expression

Property-Based Testing

Use property-based testing for invariant validation:

[Property]
public bool RoundtripSerialization(TypeExpr typeExpr)
{
    var json = JsonSerializer.Serialize(typeExpr);
    var deserialized = JsonSerializer.Deserialize<TypeExpr>(json);
    return typeExpr.Equals(deserialized);
}

Contract Testing

Test compatibility with Morphir IR format:

[Test]
public void Should_Roundtrip_With_Morphir_Elm()
{
    // Load canonical IR sample
    var json = File.ReadAllText("samples/canonical.json");
    var ir = JsonSerializer.Deserialize<IR>(json);
    
    // Serialize back
    var roundtrip = JsonSerializer.Serialize(ir);
    
    // Verify compatibility
    Assert.That(roundtrip).IsValidJson();
}

Best Practices

  1. Exhaustive Testing: Test all ADT cases
  2. Edge Cases: Test boundary conditions
  3. Roundtrip Tests: Always test serialization roundtrips
  4. Property Tests: Use property-based testing for invariants
  5. Coverage: Maintain >= 80% code coverage

4 - Tutorial: car rental pricing from F# to C#

Model pricing rules in F#, compile them to Morphir IR, and call them from a C# app

The pricing team at Acme Car Rental writes its rules in F#. The booking team writes C#. Morphir IR is the contract between them. In this tutorial you build that pipeline.

graph LR
    A[F# rules] -->|compile| B[Morphir IR]
    B -->|validate| B
    B -->|generate| C[C# library]
    C --> D[Booking app]

You need the .NET SDK 10.0 or later and dotnet-morphir (install it). The finished files are in examples/car-rental-pricing.

1. Set up the project

The pricing rules live in an ordinary F# project.

mkdir -p acme/src && cd acme
<!-- Acme.CarRental.fsproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <Compile Include="src/Rates.fs" />
    <Compile Include="src/Quote.fs" />
  </ItemGroup>
</Project>

morphir.toml tells compile what to read, so you do not repeat options:

# morphir.toml
[project]
name = "acme/car-rental"

[frontend]
language = "fsharp"

[frontend.fsharp]
project_file = "Acme.CarRental.fsproj"

The project file gives the file order. F# needs it: Quote.fs uses Rates.fs.

2. Write the rules

// src/Rates.fs
namespace Acme.CarRental

module Rates =
    type VehicleClass =
        | Economy
        | Compact
        | Suv

    let dailyRate (vehicle: VehicleClass) : int =
        match vehicle with
        | Economy -> 4000
        | Compact -> 5000
        | Suv -> 8000
// src/Quote.fs
namespace Acme.CarRental

module Quote =
    type Rental =
        { Vehicle: Rates.VehicleClass
          Days: int
          Insurance: bool }

    let insurancePerDay = 1500

    let total (rental: Rental) : int =
        let rentalCost = Rates.dailyRate rental.Vehicle * rental.Days

        if rental.Insurance then
            rentalCost + insurancePerDay * rental.Days
        else
            rentalCost

Amounts are in cents.

3. Compile

dotnet-morphir compile
compile succeeded: 2 module(s), IR version 4, /work/acme/.morphir/out/compile.dest/morphir-ir.json; no diagnostics
/work/acme/.morphir/out/compile.dest/morphir-ir.json

4. Add a discount, and meet a refused construct

Product asks for 10% off rentals of a week or more. The first attempt, in src/Discount.fs, uses a guard:

namespace Acme.CarRental

module Discount =
    let weeklyDiscount (days: int) (total: int) : int =
        match days with
        | d when d >= 7 -> total * 90 / 100
        | _ -> total

Add it to the project file, after Quote.fs, and compile:

    <Compile Include="src/Discount.fs" />
src/Discount.fs:6:18: error fsharp.unsupported_pattern_guard: Pattern guards ('when') are not supported; a Morphir pattern has no condition. Match the case, then use 'if' in its body.
compile failed (source, exit code 1); 1 error(s): error fsharp.unsupported_pattern_guard

Morphir IR has no guarded patterns, so compile refuses the code and tells you what to do. No IR is written. Follow the hint:

// src/Discount.fs
namespace Acme.CarRental

module Discount =
    let weeklyDiscount (days: int) (total: int) : int =
        if days >= 7 then total * 90 / 100 else total

    let discountedTotal (rental: Quote.Rental) : int =
        weeklyDiscount rental.Days (Quote.total rental)
compile succeeded: 3 module(s), IR version 4, /work/acme/.morphir/out/compile.dest/morphir-ir.json; no diagnostics

Every refused construct has a code and a fix. See the F# coverage reference.

5. Use the rules from C#

dotnet new console --output BookingApp
dotnet add BookingApp package Morphir.SDK.CSharp --prerelease
dotnet-morphir generate --target csharp --output BookingApp/Generated

Replace BookingApp/Program.cs:

using Acme.CarRental;

var weekTrip = new QuoteModule.Rental(new RatesModule.VehicleClass.Compact(), 7, false);

Console.WriteLine($"Before discount: {QuoteModule.Total(weekTrip)}");
Console.WriteLine($"After discount:  {DiscountModule.DiscountedTotal(weekTrip)}");
dotnet run --project BookingApp
Before discount: 35000
After discount:  31500

Each F# module becomes a static C# class (Quote becomes QuoteModule). Records and union cases become classes with value equality. Do not edit the files in Generated; run generate again when the rules change.

6. Check the IR in CI

Use --json for a machine-readable report. The exit code is 0, 1 (a source error) or 2 (a setup error).

dotnet-morphir compile --json | jq '{success, modules, diagnostics}'
{
  "success": true,
  "modules": [
    "Rates",
    "Quote",
    "Discount"
  ],
  "diagnostics": []
}

To run the type validator, compile to classic IR version 3 and validate the result. The validator does not read IR version 4 yet.

dotnet-morphir compile --ir-version 3 -o dist
dotnet-morphir validate --input dist/morphir-ir.json
0 errors, 0 warnings, 0 info, 0 hints. Analyzers: 1 ran, 0 not run, 0 failed.

-o dist puts a copy of the IR in dist for other teams. It replaces only files that an earlier compile put there.

7. One command for the booking team

dotnet-morphir compile --target csharp --generate-output BookingApp/Generated
compile succeeded: 3 module(s), IR version 4, /work/acme/.morphir/out/compile.dest/morphir-ir.json, generated 3 artifact(s) with morphir.csharp to /work/acme/BookingApp/Generated; no diagnostics

What you built

StepCommand
F# rules to IRdotnet-morphir compile
IR to C#dotnet-morphir generate --target csharp --output <dir>
Both in one rundotnet-morphir compile --target csharp --generate-output <dir>
CI checkdotnet-morphir compile --json, dotnet-morphir validate --input <ir>

Next: compile F# with the native executable, or read the compile reference.

5 - Myriad F# generation reference

Configure classic Morphir JSON generation, module files and diagnostics

Morphir.Myriad translates classic Morphir library JSON into one F# file per module during a .NET 10 F# build. See getting started for the first run and the car rental tutorial for a complete business workflow.

Configure a consumer

Reference the package and add each model once:

<ItemGroup>
  <PackageReference Include="Morphir.Myriad" Version="YOUR_VERSION" PrivateAssets="all" />
  <MorphirModel Include="models/business.json" />
  <Compile Include="Program.fs" />
</ItemGroup>

Use the VersionOverride convention if your repository manages package versions centrally. The model path resolves from the consuming project directory. Paths containing spaces are supported. The package supplies its backend, plugin and build task dependencies, so these tools do not become runtime references in the application.

SettingMeaning
MorphirModel itemOne classic library JSON input
MorphirModel.OutputDirectoryOptional model-owned output directory, relative to the project directory or absolute
MorphirMyriadHostPathOptional path to the compatible Myriad host assembly

Only assign an output directory to one model. The build rejects duplicate output ownership and generated filename collisions. Distinct input models with identical basenames receive distinct default output identities.

Version and host contract

The plugin uses Myriad.Core 0.85.0, Fabulous.AST 1.10.0, Fantomas.Core 7.0.5 and the repository’s pinned FSharp.Core. The package bundles the real Myriad 0.85.0 host and launches it with .NET major-version roll forward. A cold first restore supplies the whole generation toolchain through the single package reference.

The host’s FSharp.Core must match the plugin’s runtime identity when formatting crosses their boundary. The bundled host is aligned with the plugin’s FSharp.Core and the coordinator uses an isolated runtime; it does not change the restored NuGet cache. Package contents include upstream attribution and third-party notices. Use the package’s normal build integration to obtain this setup automatically.

The generator attribute name is morphir-fsharp, its CLR type name is MorphirFSharpGenerator, and it accepts .json. It reads GeneratorContext.InputFilename and requires the MorphirModule additional parameter, a dot-separated canonical module path such as Inventory or Domain.Types. Each call returns only the selected module as Output.Source.

For direct host use with an appropriately aligned host runtime, the important arguments are:

dotnet --roll-forward LatestMajor <Myriad.dll> \
  --inputfile <model.json> --outputfile <module.fs> \
  --plugin <Morphir.Myriad.dll> \
  --generator-filter MorphirFSharpGenerator \
  --additionalparams MorphirModule=Inventory

In Myriad 0.85.0, --generator-filter selects the CLR type name. Passing the attribute name there does not select this generator. The normal MorphirModel build integration supplies the correct selection and output ordering for every module.

Maintain custom plugins

The legacy Elm migration scaffolder creates a .NET 10 plugin project for Myriad.Core 0.85.0:

dotnet fsi .claude/skills/elm-to-fsharp-guru/scripts/generate-myriad-plugin.fsx MyPlugin
dotnet build MyPlugin/MyPlugin.fsproj

The starter parses F# input and emits no code until you implement its attribute selection and module generation functions. Myriad 0.85.0 uses Fantomas.FCS.Syntax types. Ast.fromFilename parses the file asynchronously, and Ast.extractTypeDefn takes the resulting ParsedInput, not a filename. Output.Ast contains whole SynModuleOrNamespace nodes. Implement both ValidInputExtensions and Generate on IMyriadGenerator.

The scaffold copies runtime dependencies beside the plugin. When invoking the upstream host directly, align its FSharp.Core with the plugin as described above. Building the plugin alone does not configure a consumer’s code generation.

Renovate groups Myriad’s host, core, SDK and plugins with Fantomas.Core and Fabulous.AST in fsharp-codegen, separately from general .NET updates. Before merging an upgrade, run the generator suites and the package-consumer scenarios of the black-box tests:

dotnet run --project tests/Morphir.Myriad.Tests
dotnet run --project tests/Morphir.Internal.CodeGeneration.Tests
dotnet run --project tests/Morphir.Myriad.SystemTextJson.Tests
./build.sh --target Test --skip Restore Compile --test-group integration

The scaffold regression test builds a new plugin in a sandbox and runs it through the real host. The package-consumer scenarios (tests/Morphir.BlackBox.Tests/Integration/MyriadConsumer.feature, Integration/SystemTextJsonConsumer.feature and Integration/MyriadCarRental.feature) pack the plugins from the build, and check cold restores, repeat builds, failures, and consumers using both Morphir plugins.

Supported inputs and declarations

Input must contain an explicit integer formatVersion of 1, 2 or 3 and a matching classic Library distribution. Missing versions, version strings, fractional versions, other distributions and IR 4+ fail explicitly. A document tree is not an input to this plugin.

The backend translates aliases, generic variables, closed records, custom unions, tuples, functions and unit, along with classic values and patterns. These include literals, curried application, lambdas, bindings, local recursion, destructuring, conditions, ordered matches, record construction/access/update and field functions. Open extensible records are rejected instead of losing their row information.

Package and module qualification is retained. Record fields and values use Morphir camelCase; types and cases use PascalCase. F# keywords are escaped. Unsupported collisions and cycles between separate modules produce diagnostics. Private declarations retain restricted visibility in generated code.

Generation runs as a pipeline of parse, scope validation, type validation, constant folding and F# compilation. Folding replaces mapped SDK operations on literals with their result when it matches run-time behaviour, so add 2 3 appears as 5. A model’s own function named add is left alone, and so is a result outside the Int range. Folding does not change which models are accepted.

Initial SDK mappings

Morphir SDKGenerated representation
Basics.Int, Basics.Float, Basics.Boolint, float, bool
String.String, Char.Charstring, char
List.List aa list
Maybe.Maybe a, Just, Nothinga option, Some, None
Result.Result error value, Ok, ErrResult<value, error>, Ok, Error
Basics.Order and its casesMorphir.SDK.Order
Addition, subtraction, multiplication, divisionCorresponding curried F# operators
Equality and ordered comparisonsCorresponding F# comparisons
List.map, Maybe.map, Result.mapList.map, Option.map, Result.map

Unmapped SDK names and specification-only external references fail with their qualified name. Generated code using SDK-specific types such as Order needs the Morphir.SDK runtime reference; the supplied car rental model uses built-in mappings only. Int uses the existing .NET SDK’s 32-bit representation; an out-of-range whole-number literal is rejected rather than narrowed silently. The car rental sample uses whole days and integer cents within that range.

Output, regeneration and failure behavior

By default output lives under obj/<configuration>/<target-framework>/morphir/<model-identity>/. Each module has a distinct relative .fs path, and manifest.json records dependency order. Generated compilation inputs precede handwritten files. Empty libraries produce an empty manifest; no JSON is compiled as F#.

Ordinary builds plan the current model and run generation. Identical source and manifests are not rewritten. New output is staged and published after the model succeeds, and obsolete files owned by the prior manifest are removed. A failed generation stops compilation even when an older successful output exists. Design-time builds can read the last successful manifest. Build once normally after a clean checkout to establish it.

The coordinator isolates output by model, configuration and target framework. Avoid sharing an explicit output directory across unrelated builds. Model module/declaration maps are ordered deterministically, while argument lists and match branches retain their semantic order. Source contains no timestamps or absolute input paths.

Troubleshooting

SymptomCorrective step
Missing model pathCorrect MorphirModel.Include relative to the consumer project
unsupported-versionSupply explicit classic integer version 1, 2 or 3
Decode errorCheck the reported location against that version’s wire shape
Open-record diagnosticReplace the unsupported open row with a closed model type
Module dependency cycleRefactor the model’s cross-module cycle; files remain separate
Unmapped SDK/external symbolUse a supported mapping or provide an implementation in the model
unbound-variableBind the variable in the named value, or correct its name
type-mismatchCorrect the named value. Only definite conflicts between primitive types are reported, such as a string literal under a declared Int
fsharp-generationReview the rendering reason and IR declaration; constructs without an F# representation, such as empty unions, fail without partial output
Duplicate output directory or pathAssign distinct model ownership and names
Host method/assembly mismatchUse the documented pinned packages and automatic isolated host setup
Old plugin code after repackingUse a new development package version and restore it

Business errors such as InvalidDuration in the car rental example are results from generated functions. Generation diagnostics instead identify an input file and available JSON location or qualified symbol. Plugin diagnostic logging goes to stderr. IR 4+, document trees, generated codecs/lenses and a Morphir CLI generation command remain separate initiatives.

6 - Tutorial: compile F# with the native executable

Use the AOT morphir executable and the F# frontend extension, for build agents without the dotnet tool

Acme’s build agents run the native morphir executable: one file, fast to start, and no dotnet tool setup. The native executable has no F# compiler inside. It runs the F# frontend as an extension, a separate process that it talks to over the Morphir Extension Protocol (MEP).

sequenceDiagram
    participant M as morphir compile
    participant G as morphir-fsharp-frontend
    M->>G: initialize (agree IR versions)
    M->>G: compileModel (F# documents)
    G-->>M: Morphir IR
    M->>G: shutdown, exit

This tutorial uses the project from the F# pricing tutorial (examples/car-rental-pricing).

1. Install the executable and the extension

Download morphir for your platform from the releases page: morphir-linux-x64, morphir-linux-arm64, morphir-osx-arm64 or morphir-win-x64.exe.

VERSION=0.4.0-alpha.3   # the release you want
curl -fsSL -o morphir \
  "https://github.com/finos/morphir-dotnet/releases/download/v$VERSION/morphir-osx-arm64"
chmod +x morphir && mv morphir /usr/local/bin/

The F# frontend extension is a dotnet tool. It needs the .NET 10 runtime.

dotnet tool install --global Morphir.Frontend.FSharp.Extension --prerelease

2. Try it without the extension

cd acme
morphir compile
error compile.frontend_unavailable: No registered frontend reads the language 'fsharp'. This build registers no frontend. The self-contained and framework-dependent morphir and dotnet-morphir have a built-in F# frontend; this offering uses the F# frontend process delivery morphir-fsharp-frontend. Set [frontend.fsharp].extension in morphir.toml, or use --extension, to configure a frontend extension.
compile failed (configuration, exit code 2); 1 error(s): error compile.frontend_unavailable

3. Compile with the extension

morphir compile --extension morphir-fsharp-frontend
compile succeeded: 3 module(s), IR version 4, /work/acme/.morphir/out/compile.dest/morphir-ir.json; no diagnostics
/work/acme/.morphir/out/compile.dest/morphir-ir.json

The IR is the same, byte for byte, as dotnet-morphir compile writes. Classic IR versions work too:

morphir compile --extension morphir-fsharp-frontend --ir-version 2

4. Put it in morphir.toml

So that nobody has to remember the option, add the extension to the F# table:

[frontend.fsharp]
project_file = "Acme.CarRental.fsproj"
extension = "morphir-fsharp-frontend"
morphir compile --json | jq '{success, frontend, extension: .origins.extension.key}'
{
  "success": true,
  "frontend": "org.finos.morphir.fsharp-frontend (mep)",
  "extension": "frontend.fsharp.extension"
}

(mep) shows that the extension did the work. --extension on the command line wins over the key. The value can be a command on PATH, a path, or a .dll, which runs on the dotnet host.

5. When the extension is missing

error extension.unavailable: Extension morphir-fsharp-frontend did not start: An error occurred trying to start process 'morphir-fsharp-frontend' with working directory '/work/acme'. No such file or directory
compile failed (configuration, exit code 2); 1 error(s): error extension.unavailable
CodeFix
extension.unavailableInstall the extension, or check that it is on PATH.
extension.not_admittedRequest an IR version that the extension serves (1 to 4).
extension.protocol_errorUpdate the extension.
extension.timeoutRead the extension’s messages on stderr, then run again.

Limits

  • .fsi signature files: the extension reads .fs files only. Use dotnet-morphir for a project with signature files.
  • generate, validate and the rest of the commands are the same in both executables.

Next: the compile reference, or how the F# frontend runs as an extension.

7 - Myriad System.Text.Json generation

Generate trim-safe System.Text.Json converters and contexts for F# types at build time

Morphir.Myriad.SystemTextJson is the F# counterpart of the System.Text.Json source generator. The C# generator reads only C# code, so F# projects that must be trimmed or compiled with Native AOT had two choices: write each JSON codec by hand, or use a library that serializes through reflection. This Myriad plugin removes that choice. From your records, unions and enums it generates at build time:

  • one JsonConverter<T> for each type, which reads and writes with Utf8JsonReader and Utf8JsonWriter;
  • a context type that derives from JsonSerializerContext, with a JsonTypeInfo<T> member for each type and a Default instance.

The generated code uses no reflection. You call it through the normal JsonSerializer API, as you call a C# generated context. The union encodings use the names and the JSON of FSharp.SystemTextJson, so a type can move from it with no change to its JSON.

Set up a project

Reference the plugin and the annotations package, and declare one context for an input file:

<ItemGroup>
  <PackageReference Include="Morphir.Myriad.SystemTextJson" Version="YOUR_VERSION" PrivateAssets="all" />
  <PackageReference Include="Morphir.Myriad.SystemTextJson.Annotations" Version="YOUR_VERSION" />
</ItemGroup>
<ItemGroup>
  <Compile Include="Guide/Types.fs" />
  <FSharpJsonContext Include="Guide/Types.fs" Name="AppJson" Namespace="Guide.Json" Naming="CamelCase" />
  <Compile Include="Program.fs" />
</ItemGroup>

The input file must also be a Compile item. The build compiles the generated file right after it, so files after the input can use the context. The plugin package carries the Myriad 0.85.0 host; no separate command runs. The annotations package has no dependency, and nothing in it runs: the generator reads the attributes from the source.

A project can declare more than one context, also more than one for the same input file, each with its own name and settings.

Write and read values

For these types:

type Person = { Name: string; Age: int; Nickname: string option }

the context has the member AppJson.Default.Person:

let writePerson () =
    JsonSerializer.Serialize({ Name = "Ada"; Age = 36; Nickname = None }, AppJson.Default.Person)

writePerson () returns {"name":"Ada","age":36,"nickname":null}. JsonSerializer.Deserialize(json, AppJson.Default.Person) reads it back.

The context is a normal IJsonTypeInfoResolver. To use types from more than one context, combine them. Verify checks that the options can resolve every type that the context gets from other contexts, and names the first type that they cannot resolve:

let combine (other: IJsonTypeInfoResolver) =
    let options = JsonSerializerOptions(TypeInfoResolver = JsonTypeInfoResolver.Combine(AppJson.Default, other))
    AppJson.Default.Verify options
    options

Call Verify in a unit test for each set of options that you use, so that a missing context fails a test and not a production call.

Context settings

The custom metadata of a FSharpJsonContext item are the context settings. An unknown setting stops the build, so a misspelled setting does not go unseen.

SettingValuesDefault
NameOne F# identifier: the context typeRequired
NamespaceF# identifiers joined by dotsRequired
NamingAsIs, CamelCase, PascalCase, SnakeCaseLower, SnakeCaseUpper, KebabCaseLower, KebabCaseUpperAsIs
SkippableOptionFieldsAlways (leave out None), Never (write null)Never
UnknownMembersIgnore, RefuseIgnore
AllowNullFieldstrue, falsefalse
AllowNumbersFromStringstrue, falsefalse
MapFormatObjectOrArrayOfPairs, Object, ArrayOfPairsObjectOrArrayOfPairs
UnionEncodingBase encoding and flags, or a preset, joined by commasDefault
UnionTagNameThe name of the case memberCase
UnionFieldsNameThe name of the fields memberFields
UnionTagNamingA naming value for case namesAsIs
UnionFieldNamingA naming value for case field namesThe Naming value
UnionTagCaseInsensitivetrue, falsefalse

The defaults are those of FSharp.SystemTextJson.

Attributes

Attributes on a type, a case or a field override the context settings for that item only.

AttributeWhereUse
JsonRecordRecordNaming, SkippableOptionFields, UnknownMembers, AllowNullFields
JsonUnionUnionBaseEncoding, TagName, FieldsName, TagNaming, FieldNaming, UnknownMembers, TagCaseInsensitive
JsonNameField, caseThe JSON name. More names are read; the first is written. On a case, Field = "x" names the field x
JsonPropertyNameFieldThe System.Text.Json attribute, for one name
JsonParsedValueType, abbreviationA value that is written as text and read only through its parser
JsonIgnoreTypeTypeLeave the type out of the context
JsonDefaultValueRecord fieldUse a string, Boolean or numeric literal when the member is absent
JsonDefaultEmptyCollection fieldUse the empty collection when the member is absent
JsonDefaultValueProviderRecord fieldCall an accessible static method with no arguments when the member is absent
JsonDefaultJsonRecord fieldRead an explicit JSON literal through the generated field codec when absent
JsonOmitEmptyRecord collection fieldOmit an empty collection on writing
JsonOmitDefaultRecord bool field with JsonDefaultValueOmit the value on writing when it equals the default
JsonExtensionDataRecord map fieldCapture unknown members and write them beside the named members. This is the System.Text.Json attribute
JsonVersionAliasRecord version fieldNormalize an exact legacy member/version pair to the current member and version
JsonKnownPathsRecord string collectionAccept only the declared exact critical paths
JsonConverterType, fieldThe System.Text.Json attribute: use this converter
JsonNumberHandlingNumber fieldThe System.Text.Json attribute: AllowReadingFromString for this field

For example, this record uses snake case and refuses unknown members, and its colour member has two names:

[<JsonRecord(Naming = JsonNaming.SnakeCaseLower, UnknownMembers = JsonUnknownMembers.Refuse)>]
type Settings =
    { MaxItems: int
      [<JsonName("colour", "color")>]
      Colour: string }

It is written as {"max_items":3,"colour":"red"}, and {"max_items":3,"color":"red"} reads the same value.

Records

A record is a JSON object with one member for each field.

  • A field that is not an option is required unless it declares a missing-member default or extension data. A missing required member or disallowed null fails.
  • An option is null for None, or left out with SkippableOptionFields. A missing member reads as None.
  • A member that appears twice fails. An unknown member is skipped or refused, as UnknownMembers says.
  • AllowNullFields accepts null for a field whose type accepts null (string, arrays, ResizeArray, read-only interfaces, types from other contexts).

Defaults and empty collections

The wire-rule annotations below require a generator and annotations package built after 0.4.0-alpha.2.

These annotations opt into wire rules for a particular record field. Other fields keep their existing required-member rules:

type Diagnostic =
    { Message: string
      [<JsonDefaultEmpty; JsonOmitEmpty>]
      Related: string list
      [<JsonDefaultValue(false)>]
      Retryable: bool }

An absent Related reads as []; an empty Related is omitted on writing. An absent Retryable reads as false and is still written. JsonDefaultEmpty supports sequences and maps, using their configured encoding. JsonOmitEmpty supports the same shapes and does not itself make a missing member optional. To leave out a Boolean that equals its default, as for a flag that is written only when true, add JsonOmitDefault beside JsonDefaultValue.

JsonDefaultValue takes an ordinary string, Boolean or numeric literal. Strings are values, without JSON escaping: JsonDefaultValue("false") means the string "false", while JsonDefaultValue(false) means a Boolean. The generator checks primitive token kinds and numeric bounds, and the generated field codec reads the default using its normal encoding, including parsed-value parsers and union settings.

For decimals and other values that cannot be attribute arguments, name a static provider method:

type PaymentDefaults =
    static member Amount() : decimal = 12.50M

type Payment =
    { [<JsonDefaultValue("anonymous")>]
      Name: string
      [<JsonDefaultValue(3)>]
      Retries: int
      [<JsonDefaultValue(0.5)>]
      Ratio: float
      [<JsonDefaultValueProvider(typeof<PaymentDefaults>, nameof PaymentDefaults.Amount)>]
      Amount: decimal }

The generator emits a direct method call, without reflection or build-time execution of the provider. The F# compiler checks accessibility, static invocation, arguments and the return type against the field. Use a non-generic provider type and a method callable with no arguments; return a new mutable collection on each call when records must not share it. Provider methods should be deterministic and have no side effects. They run once for each absent member and do not run for explicit values or null. Null results follow the field’s null rules, and version and critical-path constraints are checked before returning the record. Providers supply typed values directly; they do not convert strings through a parser or JSON converter.

JsonDefaultJson("...") is the explicit escape hatch for defaults expressed in JSON, such as JsonDefaultJson("null") on a field configured to accept null. It takes one complete JSON value, read through the generated field codec. Malformed JSON fails generation; codec-specific validation runs when the default is used. Declare only one default annotation per field. Explicit null never selects any default.

These defaults apply to missing members while reading. They do not themselves omit values while writing. Like System.Text.Json, this generator does not interpret System.ComponentModel.DefaultValueAttribute. Constructor selection with JsonConstructor remains outside the current record and union scope; JsonPropertyName is supported as shown in the attribute table.

Open objects

Mark one Map<string, JsonElement> or IReadOnlyDictionary<string, JsonElement> record field with the System.Text.Json JsonExtensionData attribute. Unknown members enter that map and are written directly into the record object. The map is empty when no unknown member is present. There is no wrapper named after the field. Duplicate input members and output keys colliding with named members, their aliases, or an enclosing union tag are refused. Extension data cannot be combined with UnknownMembers.Refuse or additional default/omission annotations on the same field.

Versioned member migration

For a format whose version member changed its name, declare the exact legacy and current values. For example, the MEP claims contract uses:

type Claims =
    { [<JsonVersionAlias("statementVersion", "0.1.0-draft.1", "0.1.0-draft.2", CriticalMember = "critical")>]
      ClaimsVersion: string
      [<JsonDefaultEmpty; JsonOmitEmpty; JsonKnownPaths("claimsVersion", "protocolVersions", "capabilities", "capabilities.frontend")>]
      Critical: string list }

With camel-case context naming, a reader accepts statementVersion only with draft.1 and claimsVersion only with draft.2. Both names together fail. Legacy input normalizes to draft.2 and its exact statementVersion critical path becomes claimsVersion. A legacy list naming claimsVersion fails. Writers require the current version and write only claimsVersion.

CriticalMember is optional and names a JSON member, not an F# field. When set, it must name a string collection with JsonKnownPaths that allows the current version-member path and excludes the legacy path. The paths shown above illustrate a subset; declare every path your format understands. Known parents never permit unknown children. The version field can also be a JsonParsedValue type; its parser receives the normalized current text. If a version-alias field declares a scalar or JSON-literal default, it must encode the exact current-version string. Legacy, unknown, null and non-string literal defaults fail generation. Provider results are checked against the current version at deserialization. One version alias is allowed per record. These wire annotations apply to record fields.

Unions

The union encodings and flags have the names and meanings of FSharp.SystemTextJson. For this union:

type Shape =
    | NoArgs
    | OneArg of x: int
    | TwoArgs of a: int * b: string
EncodingNoArgsOneArg 1TwoArgs(3, "t")
AdjacentTag{"Case":"NoArgs"}{"Case":"OneArg","Fields":[1]}{"Case":"TwoArgs","Fields":[3,"t"]}
AdjacentTag, NamedFields{"Case":"NoArgs"}{"Case":"OneArg","Fields":{"x":1}}{"Case":"TwoArgs","Fields":{"a":3,"b":"t"}}
ExternalTag{"NoArgs":[]}{"OneArg":[1]}{"TwoArgs":[3,"t"]}
InternalTag["NoArgs"]["OneArg",1]["TwoArgs",3,"t"]
InternalTag, NamedFields{"Case":"NoArgs"}{"Case":"OneArg","x":1}{"Case":"TwoArgs","a":3,"b":"t"}
Untagged{}{"x":1}{"a":3,"b":"t"}
FlagEffect
UnwrapFieldlessTagsA case with no field is the string of its name: "NoArgs"
UnwrapSingleFieldCasesWith AdjacentTag or ExternalTag, one field is written alone: {"OneArg":1}
UnwrapRecordCasesThe members of a case’s single record field are written in place of the field
UnionFieldNamesFromTypesA field with no name is named after its type: Int32, String, Point
UnwrapOptionNone is null and Some x is x. Without it, Some x is a union case
UnwrapSingleCaseUnionsA union with one case and one field is written as that field
AllowUnorderedTagWhen reading, the case member can come after the fields
PresetFlags
DefaultAdjacentTag, UnwrapOption, UnwrapSingleCaseUnions, AllowUnorderedTag
NewtonsoftLikeAdjacentTag, AllowUnorderedTag
ThothLikeInternalTag, UnwrapFieldlessTags, AllowUnorderedTag
FSharpLuLikeExternalTag, UnwrapFieldlessTags, UnwrapOption, UnwrapSingleFieldCases, AllowUnorderedTag

A JsonUnion attribute sets the whole encoding of one union. This union is an object tagged by a kind member:

[<JsonUnion(BaseEncoding = (JsonUnionEncoding.InternalTag ||| JsonUnionEncoding.NamedFields), TagName = "kind")>]
type Shape =
    | Circle of radius: float
    | Square of side: float

Circle 1.5 is written as {"kind":"Circle","radius":1.5}.

An untagged union must let a reader tell its cases apart. The build fails when two cases share a member name, or when two cases have no field and UnwrapFieldlessTags is not set.

Additions to FSharp.SystemTextJson

Parsed values

A type with a private representation and a parser can be read only through the parser, so a reader never builds an invalid value:

[<JsonParsedValue(TextMember = "Value")>]
type Email =
    private
    | Email of string

    member this.Value = let (Email value) = this in value

    static member Parse(text: string) : Result<Email, string> =
        if text.Contains "@" then Ok(Email text) else Error $"'{text}' is not an e-mail address."
  • The writer writes the text member (ToString by default).
  • The reader calls Parse (string -> Result<T, 'E>), or a C#-style TryParse(string, out T) with UsesTryParse = true. An error fails with the parser’s text.
  • A parsed value can be a map key, so Map<Email, int> is a JSON object.
  • For a type from another assembly, put the attribute on an abbreviation in the input file: [<JsonParsedValue(TextMember = "Value")>] type ExtensionIdText = ExtensionId. Every use of ExtensionId in the context is then a parsed value.

Cases chosen by JSON token kind

SelectByTokenKind with Untagged chooses the case by the kind of the JSON value. Each case has one field, and no two cases have the same kind (string, number, boolean, array or object):

[<JsonUnion(BaseEncoding = (JsonUnionEncoding.Untagged ||| JsonUnionEncoding.SelectByTokenKind))>]
type RequestId =
    | Number of uint64
    | Text of string

18446744073709551615 reads as Number, and "req-1" reads as Text. Each one is written back as the same token.

Exact numbers

Each integer type is read with its own TryGet member, so the full range of uint64 and int64 is kept, and a fraction or a value out of range fails. A number in a JSON string is read only with AllowNumbersFromStrings on the context or JsonNumberHandling(JsonNumberHandling.AllowReadingFromString) on a number field.

Supported types

  • Primitives: string, bool, all integer types, float32, float, decimal, char, Guid, DateTime, DateTimeOffset, DateOnly, TimeOnly, TimeSpan and JsonElement, in the System.Text.Json formats.
  • option, voption, list, arrays, Set, ResizeArray, IReadOnlyList, IReadOnlyCollection, Map, IReadOnlyDictionary, tuples, and nesting of these. A type abbreviation of one of these is a context member.
  • Records, unions (also recursive), enums (as numbers), and abbreviations declared in the input file.
  • A type from another file or assembly is read and written through the options (options.GetTypeInfo), so its context must be in the options.

Generic types that you declare (type Page<'T> = ...) are not supported. Declare an abbreviation of a closed type instead. A user type with the name of a built-in type (Guid, Set, Single) is refused, because the generator reads types by their names.

Use with Native AOT

The generated code has no trim or AOT warning. An application that uses only generated contexts publishes with PublishAot=true. FSharp.Core itself gives two summary warnings (IL2104, IL3053) in every F# AOT application.

The F# compiler does not run the trim analyzers, so publish your application with Native AOT in CI to check it. morphir-dotnet does this with a probe that runs every shape and compares the native output with JIT.

Errors

At build time, the generator reports each problem at its file, line and column, with the code MSTJ0010, and stops the build:

Types.fs(3,6): error MSTJ0010: Page: generic types are not supported; declare an abbreviation of a closed type instead.

At run time, every failure is a JsonException. System.Text.Json adds the path and the position of the value. A failure inside a nested value names each member on the way to it:

In the member 'Head' of Household: In the member 'Age' of Person: A JSON integer in the Int32 range was expected.

Move from FSharp.SystemTextJson

  1. Keep your options as context settings: the encoding flags go in UnionEncoding, the naming policies in Naming, UnionTagNaming and UnionFieldNaming, and SkippableOptionFields and MapFormat keep their names.
  2. Replace JsonFSharpConverter attributes with JsonUnion or JsonRecord. JsonName keeps its meaning.
  3. Replace JsonSerializer.Serialize(value, options) with the type info overload, JsonSerializer.Serialize(value, AppJson.Default.Person), or set TypeInfoResolver on your options.
  4. Compare the JSON of your existing tests. For the same options, the JSON is the same; morphir-dotnet checks this for every base encoding, flag and preset.

8 - Car rental from model to executable F#

Transpile and run inventory, reservation, pickup, return and billing rules

This tutorial builds a car rental workflow from classic Morphir JSON and executes the generated F#. The business rules live in the model. The handwritten F# application supplies inputs, sequences operations and displays their results.

1. Build the example

Complete getting started, including local package creation, restore and run. Work from the repository root with the .NET SDK in global.json installed. No frontend compiler, database or payment service is required.

The input is examples/car-rental/models/car-rental-v3.json. Equivalent version 1 and 2 files are supplied. The optional examples/car-rental/tools/generate-model.fsx script authors the same model using classic Morphir expressions and regenerates the fixtures with dotnet fsi.

2. Follow the model modules

ModuleBusiness responsibility
DomainImmutable vehicle and invoice records, lifecycle unions and typed errors
InventorySample car, daily rate, lookup and availability
ReservationReserve a car and capture its daily rate
PickupStart the rental from a confirmed reservation
ReturnRecord billable days and release the car
BillingProduce one invoice after return
WorkflowCompose the complete rental journey

Inventory.car describes CAR-1 at 5000 cents per day. Its initial rental status is Unreserved. Domain.RentalStatus carries the captured rate through Confirmed, Active and Completed, then carries the invoice in Invoiced. A separate immutable pending-rental chain preserves completed rentals awaiting billing, and lastInvoice stores the most recently issued invoice.

The sample models one car’s current rental at a time. Inventory lookup accepts a list, but this example does not implement a reservation calendar. Use positive whole-day durations and integer cents. Taxes, discounts, payments and persistence are outside the example.

3. Inspect generation and compilation

Build the example:

dotnet build examples/car-rental/CarRental.fsproj --no-restore

Find the generated Domain.fs, Inventory.fs, Reservation.fs, Pickup.fs, Return.fs, Billing.fs and Workflow.fs under the project’s intermediate morphir directory. Open manifest.json to see compile order. Although the JSON lists dependent modules first, the build places their dependencies before them in F# compilation.

Each generated file preserves the Car.Rental package namespace and module names. Program.fs can call the generated functions directly. Generated files are disposable build output.

4. Reserve, pick up and return a car

The supplied driver follows these calls. requireOk extracts successful results and stops the console example if a rule unexpectedly fails.

open Car.Rental

let initial = Inventory.car
let found = Inventory.lookup "CAR-1" [ initial ]
let reserved = Reservation.reserve 3 initial |> requireOk
let pickedUp = Pickup.pickup reserved |> requireOk
let returned = Return.returnCar 3 pickedUp |> requireOk
let billed = Billing.bill returned |> requireOk

The generated functions return new records. They do not mutate initial or any earlier result.

OperationRental statusInventory availability
Initial inventoryUnreservedAvailable
ReserveConfirmed 5000Reserved
Pick upActive 5000Rented
Return after three daysCompleted (5000, 3)Available
BillInvoiced invoiceAvailable

Reservation captures the rate so a later rate change does not alter this rental’s price. Return validates the actual billable day count, completes the rental, preserves its billing obligation and releases the car. A new reservation can start before the previous rental is billed; billing that completed rental preserves the new reservation and the original captured rate. The driver checks this case as well. The composed generated function Workflow.rent 3 performs the basic journey; the driver checks that both routes agree.

5. Read the invoice and rejected operations

Billing.invoice billed returns Some invoice with dailyRate = 5000, days = 3 and totalCents = 15000. The amount is 5000 × 3, expressed in cents throughout.

The driver also verifies these generated error paths:

AttemptTyped business error
Reserve the reserved carUnavailable
Pick up the initial carReservationRequired
Return the initial carActiveRentalRequired
Reserve for zero daysInvalidDuration
Return for zero billable daysInvalidDuration
Bill before returnReturnRequired
Bill an already billed rentalAlreadyBilled

Negative durations follow the same invalid-duration rule. Each rejected operation leaves its input state unchanged. These are business results returned by generated F#, so the application can handle them without parsing build diagnostics.

6. Change the model and regenerate

In models/car-rental-v3.json, locate the inventory module’s dailyRate value definition. Its body is a literal containing 5000. Change that literal to 6000. This is the single sample rate definition; references to it stay intact.

Run a fresh journey:

dotnet run --project examples/car-rental/CarRental.fsproj --no-restore

The output now includes dailyRate=6000 and invoice=18000. The build regenerated Inventory.fs; the new reservation captured 6000 cents per day and the generated billing function charged three days. No generated source or handwritten business rule changed.

Restore the sample literal to 5000 afterwards. To make a permanent authoring change across every fixture, edit the integer 5000 daily-rate expression in tools/generate-model.fsx and run:

dotnet fsi examples/car-rental/tools/generate-model.fsx

The BDD suite repeats this rate-change exercise in a disposable copy and verifies the invoice total, preventing the tutorial from drifting away from the implementation.

7. Try the other classic versions

dotnet run --project examples/car-rental/CarRental.fsproj --no-restore \
  -p:MorphirModelFile=models/car-rental-v1.json
dotnet run --project examples/car-rental/CarRental.fsproj --no-restore \
  -p:MorphirModelFile=models/car-rental-v2.json

Both runs produce the original 15000-cent invoice when their checked-in rates remain 5000. The fixture schemas differ, including v1 access wrappers and version-specific tags; the backend decodes each version before generating equivalent F#.

Troubleshooting

An Error Domain.InvalidDuration is a business rejection. Supply a positive day count. An unsupported-version build error means the JSON format is outside versions 1, 2 and 3; use a supported classic file. A missing-model or decoding diagnostic names the input path and available location; correct the configured path or model structure and rebuild.

Generation failures block compilation even if old generated files exist. Check the plugin reference for host, dependency, output ownership and module-cycle diagnostics. IR 4+, document trees and a Morphir CLI generation command are separate initiatives.