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
- Use Records: Prefer
record types for immutable data structures - Pattern Matching: Use exhaustive pattern matching for ADTs
- Validation: Implement smart constructors for validated types
- 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);
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
- Exhaustive Testing: Test all ADT cases
- Edge Cases: Test boundary conditions
- Roundtrip Tests: Always test serialization roundtrips
- Property Tests: Use property-based testing for invariants
- 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
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
| Step | Command |
|---|
| F# rules to IR | dotnet-morphir compile |
| IR to C# | dotnet-morphir generate --target csharp --output <dir> |
| Both in one run | dotnet-morphir compile --target csharp --generate-output <dir> |
| CI check | dotnet-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.
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.
| Setting | Meaning |
|---|
MorphirModel item | One classic library JSON input |
MorphirModel.OutputDirectory | Optional model-owned output directory, relative to the project directory or absolute |
MorphirMyriadHostPath | Optional 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.
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 SDK | Generated representation |
|---|
Basics.Int, Basics.Float, Basics.Bool | int, float, bool |
String.String, Char.Char | string, char |
List.List a | a list |
Maybe.Maybe a, Just, Nothing | a option, Some, None |
Result.Result error value, Ok, Err | Result<value, error>, Ok, Error |
Basics.Order and its cases | Morphir.SDK.Order |
| Addition, subtraction, multiplication, division | Corresponding curried F# operators |
| Equality and ordered comparisons | Corresponding F# comparisons |
List.map, Maybe.map, Result.map | List.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
| Symptom | Corrective step |
|---|
| Missing model path | Correct MorphirModel.Include relative to the consumer project |
unsupported-version | Supply explicit classic integer version 1, 2 or 3 |
| Decode error | Check the reported location against that version’s wire shape |
| Open-record diagnostic | Replace the unsupported open row with a closed model type |
| Module dependency cycle | Refactor the model’s cross-module cycle; files remain separate |
| Unmapped SDK/external symbol | Use a supported mapping or provide an implementation in the model |
unbound-variable | Bind the variable in the named value, or correct its name |
type-mismatch | Correct the named value. Only definite conflicts between primitive types are reported, such as a string literal under a declared Int |
fsharp-generation | Review the rendering reason and IR declaration; constructs without an F# representation, such as empty unions, fail without partial output |
| Duplicate output directory or path | Assign distinct model ownership and names |
| Host method/assembly mismatch | Use the documented pinned packages and automatic isolated host setup |
| Old plugin code after repacking | Use 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, exitThis 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
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
| Code | Fix |
|---|
extension.unavailable | Install the extension, or check that it is on PATH. |
extension.not_admitted | Request an IR version that the extension serves (1 to 4). |
extension.protocol_error | Update the extension. |
extension.timeout | Read 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.
| Setting | Values | Default |
|---|
Name | One F# identifier: the context type | Required |
Namespace | F# identifiers joined by dots | Required |
Naming | AsIs, CamelCase, PascalCase, SnakeCaseLower, SnakeCaseUpper, KebabCaseLower, KebabCaseUpper | AsIs |
SkippableOptionFields | Always (leave out None), Never (write null) | Never |
UnknownMembers | Ignore, Refuse | Ignore |
AllowNullFields | true, false | false |
AllowNumbersFromStrings | true, false | false |
MapFormat | ObjectOrArrayOfPairs, Object, ArrayOfPairs | ObjectOrArrayOfPairs |
UnionEncoding | Base encoding and flags, or a preset, joined by commas | Default |
UnionTagName | The name of the case member | Case |
UnionFieldsName | The name of the fields member | Fields |
UnionTagNaming | A naming value for case names | AsIs |
UnionFieldNaming | A naming value for case field names | The Naming value |
UnionTagCaseInsensitive | true, false | false |
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.
| Attribute | Where | Use |
|---|
JsonRecord | Record | Naming, SkippableOptionFields, UnknownMembers, AllowNullFields |
JsonUnion | Union | BaseEncoding, TagName, FieldsName, TagNaming, FieldNaming, UnknownMembers, TagCaseInsensitive |
JsonName | Field, case | The JSON name. More names are read; the first is written. On a case, Field = "x" names the field x |
JsonPropertyName | Field | The System.Text.Json attribute, for one name |
JsonParsedValue | Type, abbreviation | A value that is written as text and read only through its parser |
JsonIgnoreType | Type | Leave the type out of the context |
JsonDefaultValue | Record field | Use a string, Boolean or numeric literal when the member is absent |
JsonDefaultEmpty | Collection field | Use the empty collection when the member is absent |
JsonDefaultValueProvider | Record field | Call an accessible static method with no arguments when the member is absent |
JsonDefaultJson | Record field | Read an explicit JSON literal through the generated field codec when absent |
JsonOmitEmpty | Record collection field | Omit an empty collection on writing |
JsonOmitDefault | Record bool field with JsonDefaultValue | Omit the value on writing when it equals the default |
JsonExtensionData | Record map field | Capture unknown members and write them beside the named members. This is the System.Text.Json attribute |
JsonVersionAlias | Record version field | Normalize an exact legacy member/version pair to the current member and version |
JsonKnownPaths | Record string collection | Accept only the declared exact critical paths |
JsonConverter | Type, field | The System.Text.Json attribute: use this converter |
JsonNumberHandling | Number field | The 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
| Encoding | NoArgs | OneArg 1 | TwoArgs(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"} |
| Flag | Effect |
|---|
UnwrapFieldlessTags | A case with no field is the string of its name: "NoArgs" |
UnwrapSingleFieldCases | With AdjacentTag or ExternalTag, one field is written alone: {"OneArg":1} |
UnwrapRecordCases | The members of a case’s single record field are written in place of the field |
UnionFieldNamesFromTypes | A field with no name is named after its type: Int32, String, Point |
UnwrapOption | None is null and Some x is x. Without it, Some x is a union case |
UnwrapSingleCaseUnions | A union with one case and one field is written as that field |
AllowUnorderedTag | When reading, the case member can come after the fields |
| Preset | Flags |
|---|
Default | AdjacentTag, UnwrapOption, UnwrapSingleCaseUnions, AllowUnorderedTag |
NewtonsoftLike | AdjacentTag, AllowUnorderedTag |
ThothLike | InternalTag, UnwrapFieldlessTags, AllowUnorderedTag |
FSharpLuLike | ExternalTag, 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
- 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. - Replace
JsonFSharpConverter attributes with JsonUnion or JsonRecord. JsonName keeps its meaning. - Replace
JsonSerializer.Serialize(value, options) with the type info overload, JsonSerializer.Serialize(value, AppJson.Default.Person), or set TypeInfoResolver on your options. - 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
| Module | Business responsibility |
|---|
Domain | Immutable vehicle and invoice records, lifecycle unions and typed errors |
Inventory | Sample car, daily rate, lookup and availability |
Reservation | Reserve a car and capture its daily rate |
Pickup | Start the rental from a confirmed reservation |
Return | Record billable days and release the car |
Billing | Produce one invoice after return |
Workflow | Compose 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.
| Operation | Rental status | Inventory availability |
|---|
| Initial inventory | Unreserved | Available |
| Reserve | Confirmed 5000 | Reserved |
| Pick up | Active 5000 | Rented |
| Return after three days | Completed (5000, 3) | Available |
| Bill | Invoiced invoice | Available |
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:
| Attempt | Typed business error |
|---|
| Reserve the reserved car | Unavailable |
| Pick up the initial car | ReservationRequired |
| Return the initial car | ActiveRentalRequired |
| Reserve for zero days | InvalidDuration |
| Return for zero billable days | InvalidDuration |
| Bill before return | ReturnRequired |
| Bill an already billed rental | AlreadyBilled |
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.