Dive C2.08.2
Exposing F# to C#
Every F# declaration has a C#-visible shape. Design the seam, and the core stays idiomatic.
When C# code references an F# assembly, it gets ordinary CLR types — but their shapes follow from
the F# constructs that produced them. A module appears as a static
class; a record as a class with read-only properties; a discriminated union as a
class hierarchy with Tag and case-test helpers; a function value as
FSharpFunc<_,_>. Two facts follow. To make a record friendly to a C# constructor
and to serializers, add [<CLIMutable>]. And to keep the public surface pleasant
to call from C#, design the seam deliberately — plain types and Func<…>
out front, the idiomatic F# core behind.
Four F# declarations, four C#-visible shapes
The F# below is ordinary. What matters is how each line presents to a C# caller. A
module compiles to a class that is abstract and
sealed — the CLR encoding of a static class — so M.add is
called as M.add(a, b). A record compiles to a sealed class whose fields surface as
get-only properties. A union compiles to a base class with a nested type per case, a
Tag property, and an Is<Case> test per case. A
function value's runtime type is FSharpFunc<_,_>.
module M = let add a b = a + b // a function value in a module type Rec = { Id: int; Name: string } // a record type Color = Red | Green | Blue // a discriminated union
F# — how each one looks from C# (verified by reflection)
// module M -> a static class (abstract + sealed); call M.add(1, 2) // type Rec -> a sealed class; Id and Name are get-only properties // type Color -> a class hierarchy; a Tag property and Color.IsRed, Color.IsGreen, … // let add 1 -> a value of type FSharpFunc<int,int> (partial application)
- module → static class
- a
moduleis anabstract sealedclass; itslet-bound functions are static methods. - record → properties
- a record is a sealed class; each field is a read-only (get-only) property unless
[<CLIMutable>]is applied. - union → hierarchy
- a discriminated union is a base class with a case type each, plus a
Tagproperty andIs<Case>helpers. - function → FSharpFunc
- a function value has runtime type
FSharpFunc<_,_>; curried functions chain these, which reads awkwardly from C#.
[<CLIMutable>] opens a record to C#
A plain F# record has get-only properties and no parameterless constructor — exactly the immutable shape F#
favours, and exactly what trips a C# serializer or an ORM that constructs an object then sets its properties. The
[<CLIMutable>] attribute fixes that for the C# side: it adds a hidden
parameterless constructor and makes each property settable, while the F# view of the record stays immutable —
F# code still treats it as a value and copies with { r with … }.
[<CLIMutable>] // makes the record consumable by C#/serializers type Dto = { Id: int; Name: string } let d = { Id = 1; Name = "a" } // val d : Dto = { Id = 1; Name = "a" } // from C#: a parameterless constructor + settable Id, Name; from F#: still a value
[<CLIMutable>] on the records that cross the
seam — DTOs a C# caller constructs, types a serializer round-trips. Records that live only inside the
F# core do not need it and are better left as plain immutable records.Design the public surface for the caller
The shapes above lead to one API-design rule: keep the public signatures C#-friendly, and
let the F# core stay idiomatic behind them. A curried F# function reads as a chain of
FSharpFunc from C#, so a public callback is better typed as a
Func<…>. An option in a public return surfaces as
FSharpOption<T>, which C# can use but does not read naturally — prefer a plain
type, or a documented contract, at the boundary. Inside, the core can use curried functions,
Option, and unions freely.
C# — calling the seam designed for it
// a public surface that reads naturally from C#: int total = Api.Sum(numbers); // plain types in, plain type out var r = Api.Transform(numbers, x => x * 2); // a Func, not a curried F# function
F# — the seam in front, the idiomatic core behind
module Api = // public surface: plain types and Func for C# let Sum (xs: int[]) = Array.sum xs let Transform (xs: int[], f: System.Func<int,int>) = xs |> Array.map f.Invoke // inside the core: curried functions, Option, and unions stay idiomatic
The F# declaration
A module, a record, a union, a curried function — written the idiomatic F# way.
The C# view
A static class, get-only properties, a Tag hierarchy, an FSharpFunc — so design the public seam with plain types and [<CLIMutable>].
Inspect the C#-visible shape
Pick an F# declaration. The readout shows the C#-visible shape across the seam — the CLR type and how the
caller reaches into it. These are the shapes confirmed by reflecting on the compiled types: a
module is an abstract sealed class, a record's properties are get-only, a union
carries a Tag and Is<Case> helpers, and a function
value is an FSharpFunc.
F# declaration · the C#-visible shape
| F# declaration | C#-visible shape | how C# reaches in |
|---|---|---|
| module M | static class (abstract + sealed) | M.add(1, 2) |
| type Rec = { Id; Name } | sealed class, get-only properties | r.Id, r.Name |
| type Color = Red | … | class hierarchy + Tag | c.Tag, Color.IsRed |
| let add 1 | FSharpFunc<int,int> | f.Invoke(2) |
Each F# construct has one C#-visible shape, and the shapes are predictable. Knowing them is what lets the public seam be designed for the caller while the core stays idiomatic.
Where this leads
This dive crossed the seam C# → F# — how the declarations look from the other side. One type has no F#
equivalent, so it cannot be exposed or consumed the way the four shapes above can:
null (and its value-type sibling Nullable<T>). The last
dive contains it at the edge, lifting both into Option so the F# core never holds
one.
References
Sources
- Microsoft — F# documentation — how F# constructs appear to other CLR languages, and the
[<CLIMutable>]attribute. - Microsoft — .NET documentation — the CLR type model, static classes, and
Func<…>delegates. - FSharp.Core API reference —
FSharpFuncandFSharpOption, the runtime shapes of functions and options.
Related in this course
- C2.08.3 — The null & Nullable boundary — the one type with no F# equivalent.
- C2.08.1 — Calling C# / the BCL from F# — the other direction across the seam.
- C2.03 — From null to Option, from exceptions to Result — the
Optionthat does not export cleanly.