DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Edited on Originally published at delta1labs.com

How Glass follows type forwarding and the ExportedType table

Few pieces of .NET metadata cause as much confusion as the type forwarder. You open an old assembly — mscorlib, say, or a netstandard facade — ask your tools for System.ValueTuple, and the type is listed but has no code. It is not a stub the compiler forgot to fill in. It is a forwarder: a one-line signpost that says the type physically lives somewhere else now. A decompiler that does not understand that will show you an empty class, or an unresolved reference, and either way it has failed at its one job. This is about how Glass.NET handles the case correctly — where the forward lives in metadata, how Glass follows it (even across a chain), what it does when the target is nowhere to be found, and how it re-emits the forward when you decompile the forwarding assembly itself.

Glass.NET is a free .NET decompiler and assembly inspector with a Visual Studio-native Avalonia UI and a glass command-line tool. Everything here is static analysis: Glass reads the metadata tables and method bodies of assemblies you already have and are entitled to inspect. It never loads or runs the target.

Why forwarders exist

Over the life of .NET the Base Class Library has been reshuffled more than once. Types that lived in mscorlib or System.Core under the .NET Framework were split, during the move to .NET Core and System.Runtime, into a fine-grained set of implementation assemblies — System.Runtime, System.Collections, System.Linq, and so on. System.ValueTuple is a good concrete case: it shipped first in a standalone System.ValueTuple.dll NuGet package, and was later pulled into the platform so that System.ValueTuple the type now lives in System.Runtime.

The problem is all the code already compiled against the old location. A method compiled against System.ValueTuple.dll has a TypeRef into that assembly baked into its metadata. If the type simply vanished from there, every such reference would break at load time. The fix is to leave a forwarder behind: the old assembly keeps an entry for the type, but instead of a definition it holds a pointer to the assembly the type moved to. Old references still resolve against the old assembly; the runtime quietly follows the forward and binds to the real type. The facade assembly becomes a thin layer of nothing but forwarders.

The source that produces a forwarder is a single assembly-level attribute:

[assembly: TypeForwardedTo(typeof(System.ValueTuple))]
Enter fullscreen mode Exit fullscreen mode

You put that in the old assembly (the one that no longer contains the type), referencing the new assembly that does. The compiler turns it into metadata.

Where the forward lives: the ExportedType table

The attribute compiles to a row in the ExportedType metadata table (0x27). An ExportedType row has four columns that matter here:

  • Flags — a TypeAttributes value. A forwarder sets the IsTypeForwarder bit, 0x00200000. That bit is what distinguishes a forward from the other use of ExportedType (re-exporting a type from a secondary module of a multi-module assembly).
  • TypeName / TypeNamespace — the name of the forwarded type, e.g. ValueTuple in namespace System.
  • Implementation — a coded index. For a forwarder it points at an AssemblyRef row: the assembly the type now lives in.

Crucially, there is no row in the TypeDef table for a forwarded type. The ExportedType row carries only identity — name, namespace, target — and nothing else. No fields, no methods, no base type, no layout. That is the entire point: a forwarder is an address, not a house.

Rendered in an ILDasm-style view, the forwarder and the AssemblyRef it depends on look like this:

.assembly extern System.Runtime
{
  .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A)
  .ver 8:0:0:0
}

.class extern forwarder System.ValueTuple
{
  .assembly extern System.Runtime
}
Enter fullscreen mode Exit fullscreen mode

The forwarder keyword on .class extern is exactly the IsTypeForwarder flag; the nested .assembly extern System.Runtime is the Implementation column resolved to the AssemblyRef. There is no .class body anywhere in this assembly — only this extern signpost.

What a naive decompiler does wrong

Give that assembly to a tool that walks the ExportedType table without special-casing the forwarder bit and one of two bad things happens.

If it treats the ExportedType row like a type to display, you get an empty shell:

// WRONG — a forwarder rendered as if it were a definition
namespace System
{
    public struct ValueTuple
    {
        // (no members — this assembly has no definition for the type)
    }
}
Enter fullscreen mode Exit fullscreen mode

That body is a fiction. ValueTuple has eight-plus constructors, Create overloads, IComparable, ITuple — none of which are here, because none of them are in this assembly. Showing an empty struct implies the type has no members, which is flatly false.

The other failure mode is to resolve a reference to System.ValueTuple straight against the facade, find no TypeDef, and surface it as an unresolved type — a red squiggle in the UI, or /* unresolved */ ValueTuple. Also wrong: the type resolves perfectly well, just one hop away.

How Glass follows the forward

Glass special-cases the forwarder bit. When it resolves a type reference, or when you ask it to decompile a type by name, it does not stop at the ExportedType row. It reads the Implementation, resolves the AssemblyRef against the loaded assembly set (the resolver Glass already uses for ordinary references — framework directories, the folder of the assembly you opened, and any search paths you added), loads the target assembly, and looks for the real TypeDef there. It decompiles that.

The flow, including the chained case where a forwarder points at an assembly that itself forwards the type onward:

Because forwarders can chain — assembly A forwards to B, and B forwards the same type onward to C — Glass follows the Implementation repeatedly, not just once. It walks forwarder to forwarder until it lands on a real TypeDef, while keeping a visited-set so a pathological cycle of forwarders cannot send it into an infinite loop; a cycle is reported as a broken forward rather than hanging the UI.

Decompiling by name from the facade

From the command line you can point glass straight at the old facade and ask for the type. Glass does the forward-following for you:

glass decompile mscorlib.dll --type System.ValueTuple
Enter fullscreen mode Exit fullscreen mode
// forwarded from mscorlib.dll → System.Runtime
// definition in System.Runtime, Version=8.0.0.0
namespace System
{
    public struct ValueTuple : IEquatable<ValueTuple>,
        IStructuralEquatable, IStructuralComparable, IComparable,
        IComparable<ValueTuple>, ITuple
    {
        public static ValueTuple Create() => default;
        public override int GetHashCode() => 0;
        // ... the real members, decompiled from System.Runtime
    }
}
Enter fullscreen mode Exit fullscreen mode

The // forwarded from banner tells you Glass crossed an assembly boundary to get here; the body is the genuine definition from System.Runtime, members and all. If you want to stay on the facade and inspect the forward instead of chasing it — for example to audit exactly what a netstandard shim re-exports — --no-follow-forwarders stops Glass at the ExportedType row and prints the forward:

glass decompile mscorlib.dll --type System.ValueTuple --no-follow-forwarders
Enter fullscreen mode Exit fullscreen mode
.class extern forwarder System.ValueTuple -> System.Runtime
Enter fullscreen mode Exit fullscreen mode

Decompiling the forwarding assembly itself

When you open the facade assembly as a whole — not one type, but the assembly — the right output is not a pile of empty types. It is the set of forwarding attributes that produced those ExportedType rows. Glass reconstructs them in the assembly-attributes view:

// Assembly attributes for mscorlib.dll (facade)
[assembly: TypeForwardedTo(typeof(System.ValueTuple))]
[assembly: TypeForwardedTo(typeof(System.Collections.Generic.List<>))]
[assembly: TypeForwardedTo(typeof(System.Threading.Tasks.Task))]
// ... one per forwarder
Enter fullscreen mode Exit fullscreen mode

That is the C# that, recompiled, would regenerate the same ExportedType table. A facade decompiles back to a list of TypeForwardedTo declarations — which is exactly what a facade is.

When the target is missing

Resolution can fail. You might have the facade on disk but not System.Runtime, or an assembly might forward into something you simply do not have. Glass does not crash and does not silently drop the type. It reports the forward itself, marked unresolved, so you still learn where the type went:

glass decompile oldlib.dll --type Contoso.Legacy.Widget
Enter fullscreen mode Exit fullscreen mode
// unresolved forward
// Contoso.Legacy.Widget is forwarded to:
//   Contoso.Runtime, Version=3.0.0.0, PublicKeyToken=a1b2c3d4e5f60708
// target assembly not found on any search path — showing the forward only
.class extern forwarder Contoso.Legacy.Widget -> Contoso.Runtime
Enter fullscreen mode Exit fullscreen mode

The rest of the assembly decompiles normally; only the types whose targets are absent fall back to this forward-only view. You get the fully qualified name of the destination assembly — enough to go find it and point Glass at it with an added search path — rather than an exception or a blank.

Why this matters when you are reading real code

Type forwarding is invisible until it is not. Most of the time the runtime follows forwards for you and you never think about it. But the moment you open a facade assembly in a decompiler — tracing a BCL method, debugging why a reference binds to the assembly it does, understanding a netstandard shim — the forwarders are suddenly the whole story, and a tool that renders them as empty types or unresolved references actively misleads you. Glass treats the ExportedType table as what it is: a set of signposts. It follows them to the real definitions, chains and all; it re-emits them faithfully when you decompile the facade; and it tells you honestly where a type went when it cannot follow. That is the difference between a decompiler that understands metadata and one that merely lists it.

Top comments (0)