If you have ever opened an F#-built DLL in a decompiler expecting to see let and match and instead found classes with names like Tag and NewCircle, this post is for you. F# and C# are different languages, but they share one back end: both compile to ECMA-335 CLI metadata and IL. A decompiler reconstructs C# from that IL, so pointing a C# decompiler like Glass at an F# assembly gives you a faithful, readable — if slightly unusual — C# rendering of the F# compiler's output. Knowing how each F# construct is lowered turns that rendering from confusing into obvious.
The fingerprint: FSharp.Core and CompilationMapping
Two things immediately mark an assembly as F#-origin. First, a reference to FSharp.Core.dll, the runtime every F# program depends on. Second, the [CompilationMapping] attribute sprinkled across types and members. That attribute carries a SourceConstructFlags enum value — Module, SumType, RecordType, UnionCase, Field, Value, and friends — that records what the element was in F# source. A C# decompiler prints these attributes as-is, so they double as a legend: a type tagged SourceConstructFlags.SumType is a discriminated union, one tagged Module is an F# module, and so on.
<br> .b{fill:none;stroke:#64748b;stroke-width:1.5}<br> .t{fill:currentColor;font-size:13px}<br> .s{fill:#64748b;font-size:11px}<br> .ar{stroke:#64748b;stroke-width:1.5;marker-end:url(#m)}<br>
F# source
type Shape =
| Circle of float
type Point = {X;Y}
IL + metadata
classes + Tag
[CompilationMapping]
FSharp.Core ref
Glass C# view
abstract Shape
+ nested Circle
Point : IEquatable
Names are preserved, so the C# rendering is readable; the CompilationMapping flags say what each type originally was.
A worked example
Take a small, idiomatic piece of F#:
type Shape =
| Circle of radius: float
| Rectangle of width: float * height: float
type Point = { X: float; Y: float }
module Geometry =
let area shape =
match shape with
| Circle r -> System.Math.PI * r * r
| Rectangle (w, h) -> w * h
Decompiled to C#, Glass shows roughly this shape (trimmed for clarity):
[CompilationMapping(SourceConstructFlags.SumType)]
public abstract class Shape {
public abstract int Tag { get; } // discriminates the cases
public static class Tags { public const int Circle = 0, Rectangle = 1; }
[CompilationMapping(SourceConstructFlags.UnionCase, 0)]
public class Circle : Shape {
public readonly double radius;
public override int Tag => 0;
}
[CompilationMapping(SourceConstructFlags.UnionCase, 1)]
public class Rectangle : Shape {
public readonly double width, height;
public override int Tag => 1;
}
}
[CompilationMapping(SourceConstructFlags.RecordType)]
public sealed class Point : IEquatable<Point>, IStructuralEquatable, IComparable<Point>, IComparable {
public double X { get; }
public double Y { get; }
// structural Equals / GetHashCode / CompareTo generated by the F# compiler
}
[CompilationMapping(SourceConstructFlags.Module)]
public static class Geometry {
public static double area(Shape shape) {
switch (shape.Tag) {
case 0: { double r = ((Shape.Circle)shape).radius; return Math.PI * r * r; }
default: { var rc = (Shape.Rectangle)shape; return rc.width * rc.height; }
}
}
}
Every F# construct is now legible as a C# pattern: the union became an abstract base with nested subclasses and an integer Tag; the record became a sealed class carrying the structural Equals/GetHashCode/CompareTo the F# compiler synthesises; the module became a static class; the match became a switch on Tag. Pattern reading, once you know the mapping, is mechanical.
The rest of the vocabulary
-
Functions and currying. A let-bound function on a module is a static method. A curried or partially-applied function surfaces as an
FSharpFunc<,>value, often with a compiler-generated closure class holding the captured arguments. Higher-order code reads asFSharpFuncinvocations rather than lambdas. -
Tuples. F# tuples become
System.Tuple<...>(reference tuples) orSystem.ValueTuple<...>for struct tuples; element names are not preserved at the IL level. -
Options.
'T optionisFSharpOption<T>withSome/None;Noneis the null reference, which is why you sometimes see null checks where the F# saidNone. -
Entry point.
[<EntryPoint>]becomes astatic int Main(string[])tagged with[CompilationMapping(SourceConstructFlags.Value)]plus[EntryPoint]-style marking. -
Nullary union cases (like a
None-style tag) are usually exposed as cached singleton instances rather than fresh allocations.
Reading an unfamiliar F# library
This is where a C# decompiler earns its keep: you have a third-party F# assembly with no source, and you need to understand its public surface. In Glass, open the assembly, and the [CompilationMapping] flags immediately tell you which types are unions versus records versus plain classes. Expand a union to see its cases and payloads; expand a module's static class to read the functions; follow a switch (x.Tag) to see how a function branches on a union. Because F# does not strip or mangle names, the identifiers you see are the ones the author wrote — the only translation you are doing in your head is F#-construct → C#-shape, and the CompilationMapping legend hands you that for free.
The honest limit: you are reading the lowered form. Computation expressions have been desugared into Bind/Return calls, inline functions may have vanished into their call sites, and active patterns appear as ordinary method calls returning options. You can always recover the behaviour; you cannot always recover the elegant F# that produced it.
Doing this with Glass
Glass is a free, VS-native .NET decompiler, and because it reconstructs C# from CLI metadata it reads F#-compiled assemblies as readily as C# ones — showing the [CompilationMapping] attributes, the union class hierarchies, and the record equality members so you can navigate an unfamiliar F# library the same way you would any .NET assembly. It is the same workflow as decompiling C# records or iterator state machines; F# just adds one legend to learn.
Grab it from the Glass page — it is free — and open your first F# assembly.
Top comments (0)