Skip to content
← All posts
· Delta1 Labs Decompilation.NETF#

Decompiling F#-Compiled Assemblies in a C# Decompiler: Reading Unions, Records, and Modules

F# compiles to the same IL as C#, so a C# decompiler shows F# assemblies as C#. Here is how discriminated unions, records, modules, and curried functions appear — and what the CompilationMapping metadata tells you.

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.

F# source lowered to IL, then read back as C# 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 as FSharpFunc invocations rather than lambdas.
  • Tuples. F# tuples become System.Tuple<...> (reference tuples) or System.ValueTuple<...> for struct tuples; element names are not preserved at the IL level.
  • Options. 'T option is FSharpOption<T> with Some/None; None is the null reference, which is why you sometimes see null checks where the F# said None.
  • Entry point. [<EntryPoint>] becomes a static 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.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.