jonnifyfsharp

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.

C2.08.2 · part 1

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 module is an abstract sealed class; its let-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 Tag property and Is<Case> helpers.
function → FSharpFunc
a function value has runtime type FSharpFunc<_,_>; curried functions chain these, which reads awkwardly from C#.
C2.08.2 · part 2

[<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
Reach for [<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.
C2.08.2 · part 3

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# declares C# view module M let add a b static class M.add(1, 2)
F# module M → the C# view is a static class · abstract + sealed; call M.add(1, 2)
Without JavaScript: each F# declaration and its C# view
F# declarationC#-visible shapehow C# reaches in
module Mstatic class (abstract + sealed)M.add(1, 2)
type Rec = { Id; Name }sealed class, get-only propertiesr.Id, r.Name
type Color = Red | …class hierarchy + Tagc.Tag, Color.IsRed
let add 1FSharpFunc<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

Related in this course