Skip to content
← All posts
· Delta1 Labs Nebula.NETObfuscation.NETDeep Dive

Obfuscating an F# assembly: discriminated unions, the [CompilationMapping] attribute, and what FSharp.Core reflects on

F# compiles to the same IL as C#, so a renamer can obfuscate it — but F# leans on a layer of compiler-emitted metadata that C# has no equivalent for, and a renamer that does not understand it quietly breaks things that still build. A discriminated union is not a sum type to the CLR; it is an abstract class with one nested subclass per case, a Tag integer, and a [CompilationMapping(SourceConstructFlags.SumType)] attribute that FSharp.Core reflection reads to recover the cases. Rename the case types blindly, or strip that attribute as "unused metadata", and FSharpType.GetUnionCases returns nothing, %A printing stops naming your cases, and any F#-aware serializer loses the shape — all with a clean compile. This post reads the IL a union, a record and a module actually emit, shows exactly which names and attributes are load-bearing because FSharp.Core observes them at runtime, and configures Nebula to rename the logic while preserving the reflection contract F# depends on.

F# compiles to the same IL and the same metadata tables as C#, so on paper an obfuscator should treat it identically — rename the identifiers, encrypt the strings, flatten the control flow. In practice F# carries a second layer of meaning that C# has no equivalent for, and that layer is invisible until something reflects over it. The compiler records, in metadata, facts the CLR itself does not care about: this type is a discriminated union, that one is a record, this static class is really a module. FSharp.Core reads those facts back at runtime, and a renamer that treats them as dead weight breaks code that still compiles cleanly.

This post reads what F# actually emits, identifies exactly which names and attributes are load-bearing, and shows how Nebula obfuscates the logic while leaving the F# runtime contract intact.

A discriminated union is an abstract class with a Tag

Start with the construct that makes F# F#. This union:

type Shape =
    | Circle of radius: float
    | Rectangle of width: float * height: float

is not a sum type to the CLR — the CLR has no sum types. The compiler lowers it to an abstract base class Shape, one nested subclass per case, an integer Tag that says which case an instance is, static factory members, and — crucially — attributes that record the F# shape. Decompiled, the skeleton reads:

.class public abstract auto ansi serializable Shape
       extends [runtime]System.Object
{
  .custom instance void [FSharp.Core]CompilationMappingAttribute::.ctor(valuetype SourceConstructFlags)
          = ( 01 00 01 00 00 00 00 00 )   // SourceConstructFlags.SumType

  .class nested public sealed auto ansi serializable Circle
         extends Shape
  {
    .custom instance void CompilationMappingAttribute::.ctor(SourceConstructFlags, int32, int32)
            = ( ... )   // UnionCase, caseIndex 0
    .method public hidebysig specialname instance float64 get_radius() cil managed
  }
  .method public static class Shape NewCircle(float64 radius) cil managed
  .method public hidebysig specialname instance int32 get_Tag() cil managed
}

Three kinds of thing live here, and they are not equally free to rename. The [CompilationMapping(SourceConstructFlags.SumType)] attribute on Shape is the flag FSharp.Core looks for to even recognise the type as a union. The Tag integer and its per-case values are the structural contract — pattern matching is a switch on Tag. And the case names (Circle, Rectangle) and field accessors (get_radius) are the reflective contract: they are the strings FSharp.Core hands back when something asks “what are this union’s cases?”

What compiles, and what FSharp.Core reads backclass Shape (abstract)[CompilationMapping(SumType)] preserveint32 Tag — ordering preservenested Circleget_radius()name preservenested Rectangleget_width/heightname preservemethod bodies → a.b.c()locals, private helpers — rename freelyFSharp.Core reflection, at runtimeFSharpType.GetUnionCases(t)→ reads the SumType attributeFSharpValue.GetUnionFields(v)→ reads Tag + the case nameprintf "%A" shape→ prints "Circle 3.0" from the nameobserved at runtime — not in your source

Why a clean build still breaks at runtime

Here is the trap in one example. Suppose a naive renamer turns Circle into a and Rectangle into b, because they look like ordinary nested classes. The IL still binds — pattern matching switches on Tag, which is an integer, so matching still works and the assembly runs its core logic fine. Then something calls:

printfn "%A" (Circle 3.0)

%A is the structured formatter, and it does not read your source — it reflects. It calls FSharpValue.GetUnionFields, which uses the [CompilationMapping] attribute to find the cases and then reads the case name out of the member metadata to print. After the rename that name is a, so instead of Circle 3.0 you get a 3.0 — or, if the renamer also stripped the [CompilationMapping] attribute as “unused”, GetUnionFields no longer recognises Shape as a union at all and throws ArgumentException: The type Shape is not a union type. Equality and comparison — which the F# compiler generated for the union and which also walk the structure — can fail the same way. None of this is a compile error. It is a runtime break in code you did not write, triggered by renaming you thought was free.

Records tell the same story in miniature:

type Point = { X: float; Y: float }

compiles to a class with [CompilationMapping(SourceConstructFlags.RecordType)] and each property carrying a SourceConstructFlags.Field mapping with its index. FSharpType.GetRecordFields reads those back by name and order. Rename X/Y blindly and an F#-aware serializer that round-trips the record by field name writes a/b into your JSON — a silent data-format change, not a crash, which is worse. A module ([CompilationMapping(SourceConstructFlags.Module)] on the static class) is the same idea for top-level functions.

The rule: preserve the reflective contract, rename the logic

The load-bearing surface in an F# assembly is specific and small:

  • the [CompilationMapping] attributes — they encode that a type is a union, record or module, and stripping them is what turns reflection blind;
  • the Tag integers and their ordering — the structural identity of each case;
  • the union-case type names, record-field names, and their accessors — the strings FSharp.Core reflection surfaces;
  • anything you explicitly round-trip through a serializer by name.

Everything else renames: the bodies of your module functions, locals, private helpers, and internal types that never cross a reflection boundary. This is exactly the split you already make on a C# assembly that uses reflection — the [CompilationMapping] metadata is F#‘s version of the [JsonPropertyName] and [DataContract] attributes a C#-aware obfuscator already knows to respect.

Configuring Nebula

Nebula’s auto-detect already preserves reflection and serialization targets, and it treats the [CompilationMapping] metadata as a signal to keep the F# shape rather than strip it as unused. So the default config does most of the work; you add explicit excludes only for types you serialise by name or reflect over yourself.

{
  "inputs": ["bin/Release/net8.0/MyFSharpApp.dll"],
  "outputDirectory": "protected",
  "preservePublicApi": true,
  "autoDetect": true,
  "exclude": [
    "MyFSharpApp.Domain.Shape",
    "MyFSharpApp.Dto.Point"
  ],
  "excludeStrings": ["Circle", "Rectangle"],
  "encryptStrings": true,
  "controlFlowObfuscation": true,
  "controlFlowExclude": []
}

A few deliberate choices. autoDetect stays on — it is what keeps the [CompilationMapping] attributes and the reflected member names intact without you listing every union. preservePublicApi is on because an F# library’s public union and record types are part of its surface for F# consumers, who bind to those names at compile time. The explicit exclude entries are the two types this app serialises or formats by name — a type full name there preserves the type and all its members, so the union cases and record fields keep their names. And controlFlowObfuscation applies freely to the method bodies: flattening the IL of a module function changes nothing the reflection layer observes, because reflection reads metadata, not control flow.

Then the non-negotiable step, the same one every obfuscation post on this blog ends on: run the F# paths against the obfuscated binary. A single test that round-trips each union and record through %A, through FSharpValue.GetUnionFields, and through whatever serializer you use — executed against the protected output in CI — turns “the metadata survived” from a hope into a green build. The F# runtime contract breaks at runtime or not at all, so it is runtime you have to check.

The takeaway

F# obfuscates as well as C# does, with one extra thing to respect: the compiler-emitted metadata that makes a union a union, a record a record, and a module a module. The CLR ignores it; FSharp.Core lives on it. Preserve the [CompilationMapping] attributes, the Tag ordering, and the case and field names that reflection reads, and rename everything else — and your F# assembly comes out as hard to read as any C# one, while %A still prints Circle, GetUnionFields still finds your cases, and your serializer still writes the field names you shipped. The logic is obfuscated; the contract F# depends on is not.

Try Nebula.NET

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