Skip to content
← All posts
· Delta1 Labs FileLocalTypesGlass

Decompiling file-local types: recovering the file modifier from a mangled metadata name

The C# 11 file modifier has no attribute and no flag. The compiler encodes it by mangling the type name into an unspeakable <File>F0__Type form and emitting the type as assembly-internal. A decompiler that prints that name verbatim produces code that will not compile. Here is how Glass.NET reads the mangling back into file class.

C# 11 added the file access modifier: a type declared file class Parser is visible only inside the single source file that declares it. Two files in the same project — or two source-generated files that each emit a helper — can both declare a file class Parser without colliding, and neither can be touched from anywhere else. It exists mostly for the benefit of source generators, which emit support types into a user’s compilation and must not leak names that clash with the user’s own code or with another generator’s.

From a decompiler’s point of view file is an interesting problem because, like several recent C# features, it leaves no attribute behind. There is no [FileLocal] marker, no custom modifier, no flag in the TypeDef row that says “this type was declared file.” The compiler encodes the modifier in the one place that survives into metadata and is guaranteed unique: the type’s name. It mangles the declared name into a form that is deliberately not a legal C# identifier, and it drops the visibility down to assembly-internal. A decompiler that reads the TypeDef table and prints what it finds will emit a type called <Validators>F0__Parser with internal accessibility — which is neither what the author wrote nor anything the C# compiler will accept. Reconstructing the original means recognising the mangling, reversing it, and putting the recovered type back in a file bucket.

This post walks the full round trip: the source, the metadata the compiler emits, and the C# Glass.NET reconstructs.

source — Validators.csfile sealed class PhoneRule { ... }visible only in this filemetadata — TypeDef row.class private sealed'<Validators>F0__PhoneRule'no attribute · name is the signalGlass reconstructionstrip <File>F0__restore file modifierre-bucket by file idthe mangled name, decoded'<Validators>F0__PhoneRule'<Validators> = file identifier (source file name)F0 = disambiguating ordinal · __ = separator · PhoneRule = declared namewhy it cannot be printed verbatimangle brackets are reserved for compiler-generated names — '<Validators>F0__PhoneRule' is not a legal C# identifier, so emitting it as-is yields code that will not compile

What the author wrote

A file type usually keeps a small helper private to one file. Here Validators.cs has a file-local rule plus a public façade that uses it:

// Validators.cs
file sealed class PhoneRule
{
    public static bool IsValid(string s) => s.Length == 10;
}

public static class Contact
{
    public static bool Check(string phone) => PhoneRule.IsValid(phone);
}

PhoneRule is file-scoped, so nothing outside Validators.cs can name it — not another file in the same assembly, not a consumer referencing the DLL. Contact, declared normally, calls it freely because it lives in the same file.

What the compiler emits

Two things happen to PhoneRule on the way to metadata. First, its name is mangled. The compiler builds a metadata name from a file identifier (derived from the source file’s name), an ordinal that disambiguates files that happen to share a name, and the declared type name, wrapped so the result cannot be written in source:

.class private sealed auto ansi beforefieldinit
       '<Validators>F0__PhoneRule'
       extends [System.Runtime]System.Object
{
  .method public hidebysig static
          bool IsValid (string s) cil managed
  {
    ldarg.0
    callvirt instance int32 [System.Runtime]System.String::get_Length()
    ldc.i4.s 10
    ceq
    ret
  }
}

Note what is not there: no CompilerGeneratedAttribute, no custom attribute of any kind announcing the modifier. The <Validators>F0__PhoneRule name is the entire record of the file keyword. The < and > are the tell — they are reserved exactly so that user code can never produce an identifier that collides with a compiler-synthesized one.

Second, the type’s accessibility drops to assembly-internal. A top-level type can only be public or internal in the CLI type system — there is no finer-grained visibility at the metadata level — so the compiler emits PhoneRule as internal and relies on the unspeakable name to enforce the stronger “one file only” rule at compile time. In ILDasm a top-level internal type reads as .class private, which is why the row above says private even though you never wrote private.

The call site inside Contact.Check references the mangled name directly, because by the time IL is emitted the only name PhoneRule has is the mangled one:

.method public hidebysig static bool Check (string phone) cil managed
{
  ldarg.0
  call bool '<Validators>F0__PhoneRule'::IsValid(string)
  ret
}

Every reference to a file-local type inside its own file — base types, field types, method calls, typeof — carries this mangled token. And because the C# rules forbid a file-local type from appearing in the signature of a non-file member, the mangled token is guaranteed to appear only within the types that came from the same source file. That bound matters for reconstruction: Glass never has to chase a mangled reference across a file boundary.

The naive decompilation, and why it fails

A decompiler that walks the TypeDef table and renders each row literally produces this:

internal sealed class <Validators>F0__PhoneRule   // will not compile
{
    public static bool IsValid(string s) => s.Length == 10;
}

That is wrong on two counts. The name <Validators>F0__PhoneRule is not a legal identifier, so the file does not even parse. And internal is the metadata accessibility, not the source modifier the author chose — re-emitting internal discards the file intent entirely and, worse, would make the type visible to the whole assembly if you could somehow rename it, changing the program’s meaning. A faithful decompiler has to undo both transformations together.

How Glass.NET reads it back

Glass treats the mangled name as a structured token rather than an opaque string. Its reconstruction of a type name runs a small decoder:

  • A top-level type whose name matches < file >F ordinal __ name is a file-local type. Glass splits it into the file identifier (Validators), the ordinal (0), and the declared name (PhoneRule).
  • The declared name is restored as the type’s source identifier, so PhoneRule appears exactly as written.
  • The modifier is reconstructed as file, not the internal the metadata reports — the mangled shape is stronger evidence of the author’s intent than the assembly-level accessibility, which the compiler was forced to use only because no narrower one exists at the metadata layer.
  • Every mangled reference to this type elsewhere in the assembly — the call '<Validators>F0__PhoneRule'::IsValid inside Contact, any field or base-type use — is rewritten to the restored PhoneRule.

The file identifier is more than decoration. When Glass lays source back out, it uses the <Validators> bucket to group every type that shares it, so file-local types land together in a recovered file that reflects their original grouping — and, critically, two different source files that each declared a file class Parser (distinct mangled names, <Validators>F0__Parser and <Rules>F0__Parser) are reconstructed into separate files, which is the only arrangement that compiles. Collapsing them into one file would reintroduce the name clash the file modifier was invented to prevent.

The reconstructed source round-trips cleanly:

// Glass groups this type into a recovered file keyed by its file identifier
file sealed class PhoneRule
{
    public static bool IsValid(string s)
    {
        return s.Length == 10;
    }
}

public static class Contact
{
    public static bool Check(string phone)
    {
        return PhoneRule.IsValid(phone);
    }
}

The file modifier is back, the mangled name is gone from both the declaration and the call site, and the public Contact façade reads exactly as it did in source.

Doing it from the CLI

Point Glass at the assembly and let it reconstruct the whole thing, file grouping and all:

# Reconstruct to a source tree; file-local types are regrouped by file identifier
glass decompile Validators.dll -o ./recovered

# Inspect a single type, mangled name and all
glass decompile Validators.dll --type PhoneRule

When you want to see the raw metadata the decoder is working from — the .class private row with the <Validators>F0__PhoneRule name and the mangled call token — drop to the IL view:

glass il Validators.dll --type "<Validators>F0__PhoneRule"

Seeing the mangled name beside the recovered file class is the clearest way to understand what the modifier actually costs at the metadata layer: nothing but a naming convention and a dropped accessibility, reconstructed by reading the convention back.

Why this matters

File-local types show up most often in generated code, which is exactly the code you are most likely to be reading in a decompiler — you have a DLL, a source generator ran during its build, and you want to understand what it produced. If your tool prints internal class <Thing>F0__Helper, you learn almost nothing and you cannot recompile what you see. Recovering the real file class Helper, grouped the way the generator emitted it, turns an unreadable mangled dump back into the code that was actually written — which is the whole point of decompiling your own or a third-party assembly to understand, debug, or interoperate with it.

Glass.NET is a free .NET decompiler with a Visual Studio-native Avalonia UI and a CLI/MCP interface, built to reconstruct exactly these modern C# constructs — the ones that live in metadata conventions rather than in attributes — so what you read back is the source, not the compiler’s bookkeeping.

Try Nebula.NET

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