Rules for F# code that C# calls, and C# code that calls F#, in morphir-dotnet. Covers parsed domain types, nullness metadata, TryParse/Create members, Match members for unions, collections, option and null, equality, ToString and AOT safety, and the .fsproj settings. Use when you add or change a public F# type, member or module that a C# project uses; when C# code calls F# types (unions, records, Result, option, list); when you create an F# project that C# references; or when you review a diff that crosses the F#/C# boundary. Triggers include "F# interop", "C# interop", "call F# from C#", "F# union in C#", "MaybeNullWhen", "NotNullWhen", "CompiledName", "F# nullness", "Nullable enable fsproj", "oblivious", "TryParse", "parse don't validate".
97%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
The canonical home for this skill is fsharp-csharp-interop in finos/morphir-dotnet
morphir-dotnet writes domain types in F# and keeps the CLI edge in C# where C# is required (System.Text.Json source generation, System.CommandLine, Foundatio.Mediator). This skill holds the rules for the boundary between the two languages.
The rules apply only to the C# surface: the F# types, members and modules that a C# project calls. Inside F#, write idiomatic F#.
Code examples for each rule are in references/patterns.md. Read that file before you write new boundary code.
.cs files for the F# namespace.Every F# project that C# references sets:
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsTrimmable>true</IsTrimmable>
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
<IsAotCompatible>true</IsAotCompatible>
<GenerateDocumentationFile>true</GenerateDocumentationFile><Nullable>enable</Nullable> makes F# write nullable metadata. Without it, C# treats every F# reference type as "oblivious" and gives no warning when it passes null. The C# projects already set <Nullable>enable</Nullable> and treat warnings as errors.
static member Parse(text) : Result<T, ParseFailure> for F# callers, static member TryParse(text, out result, out failure) : bool for C# callers, and static member Create(literal) for literals in source code. Create throws on invalid text. A unit test calls Create for each literal.[<Struct>] for a parsed value. default(T) skips the parser.[<CompiledName("PascalCase")>] when a module function must be called from C#.'a -> 'b) in a C# parameter or return type. Use Func<...> or Action<...>.namespace, not in a top-level module, and use [<RequireQualifiedAccess>] on unions and modules.Task<T> or ValueTask<T> and take a CancellationToken. Never return Async<'T> to C#.with on an F# record. Where C# must change one field, give the record a With... member (for example WithHosts).== and != on an F# union or record compare references, because F# defines no C# equality operators. Compare with Equals.T | null. Inside F#, it is option. Convert at the surface with Option.toObj and Option.ofObj.string | null, so C# can pass what it has and the parser decides.TryParse and similar members use a non-nullable out type with [<Out; MaybeNullWhen(false)>] on the result and [<Out; MaybeNullWhen(true)>] on the failure, and assign Unchecked.defaultof<_> to the one that is not set. C# flow analysis then knows which one is set.byref<T | null> for an out value. F# does not write the nullness of a byref element into the metadata, so C# reads the value as never null and gives no warning when code reads it on the wrong branch.null!, reflection and deserialization can still put null into a value. Parse at every real input boundary.Match member with one Func for each case. The signature forces C# to handle every case. Do not ask C# to use Tag, IsX or type tests.MorphirHost.Native). For a case with fields, give C# a named factory member when it needs one.WireValue member. C# reads it and does not map cases by hand.IReadOnlyList<T>, IReadOnlyCollection<T>, ImmutableArray<T> or a project type such as NonEmptySet<T>.list, Set, Map or seq that C# must enumerate more than once.[<CustomEquality; CustomComparison>] with StringComparison.Ordinal.ToString() on every type on the surface. The default prints through reflection (%A), which is not AOT-safe and gives unstable text.sprintf "%A", printfn "%A" or string on a record or union in code that ships. Use string interpolation over explicit members.Use this list when you review a diff that crosses the boundary. Report each failed item with its file, line and rule number.
[<Struct>] parsed value; Create literals are tested.string, C# enum or flag on the surface where a parsed type or union belongs.Async<'T> on the C# surface; C# compares F# values with Equals, never == or !=.option on the C# surface; TryParse uses MaybeNullWhen on non-nullable out types, never byref<T | null>; external text enters as string | null.Tag, IsX or F# case types; Match or an F# function is used instead.list, Set or Map on the C# surface.ToString overridden; no %A in shipped code.To review as a separate agent, run the reviewer agent fsharp-csharp-interop-reviewer (Codex: fsharp_csharp_interop_reviewer). The repository defines it for Claude Code, Codex, GitHub Copilot, Cursor and Grok Build. On a tool with no file-based agents (for example Muse Code), start a subagent with this prompt:
Read
.agents/skills/fsharp-csharp-interop/SKILL.mdandreferences/patterns.md. Get the diff withgit diff <base>...HEAD -- '*.fs' '*.fsi' '*.fsproj' '*.cs'. Apply the review checklist only to the F#/C# boundary. Do not edit files. Report each finding aspath:line: rule N: problem. fix., most severe first. If there is no finding, say so.
docs/contributing/fsharp-coding-guide.md: F# style, including the nullness section..agents/aot-optimization.md: AOT and trimming rules.AGENTS.md section 5: functional design rules.finos/morphir-dotnet
da2167e
Canonical home
since Nov 11, 2026
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.