How Glass follows type forwarding and the ExportedType table
When a type moves from mscorlib to System.Runtime, the old assembly keeps a forwarder — an ExportedType row with the forwarder flag pointing at a new AssemblyRef. A forwarder has no members, so a decompiler must not render it as a type: it has to follow the forward to the real definition. This walks through where the forward lives in metadata, how Glass.NET resolves a forwarding chain, how it degrades gracefully when the target assembly is missing, and how it re-emits [assembly: TypeForwardedTo(...)] when you decompile the facade itself.
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))]
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
TypeAttributesvalue. A forwarder sets theIsTypeForwarderbit,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.
ValueTuplein namespaceSystem. - 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
}
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)
}
}
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
// 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
}
}
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
.class extern forwarder System.ValueTuple -> System.Runtime
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
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
// 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
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.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.