Skip to content
← All posts
· Delta1 Labs Glass.NETDecompilation.NETDeep Dive

Decompiling nullable reference types: where string? actually lives in the metadata

string and string? are the same type to the runtime — System.String, one TypeRef, no difference in any signature. The question mark exists only at compile time, and the compiler has to smuggle it into the assembly somewhere so that consumers of your library get the right warnings. It does that with two embedded attributes, NullableAttribute and NullableContextAttribute, a three-value byte vocabulary, a preorder flattening of generic type trees into byte arrays, and an aggressive compression scheme that omits whatever matches the enclosing default. This post reads those bytes directly from the IL, shows the exact blob encoding for the single-byte and array forms, walks through how a decompiler reconstructs ?, notnull and class? from them, and explains why skipping this step silently changes a library's public contract.

Ask the runtime what the type of a string? property is and it will tell you System.String. Ask it about a string property and it will say the same. There is one TypeRef for System.String in the assembly, both properties point at it, and the signature blobs of their getters are byte-for-byte identical. Nullable reference types are the most thorough compile-time-only feature in C#: they change warnings, not code, and they leave no trace in the type system at all.

They do leave a trace somewhere, because they have to. If you publish a library compiled with #nullable enable, a consumer compiling against it gets warnings based on which of your parameters accept null and which of your returns may produce it. That information crosses the assembly boundary, so it must be in the metadata. The compiler stores it in custom attributes — two of them, embedded privately in every assembly that uses the feature, with a compact byte encoding and a compression scheme designed to keep the metadata small. This post reads those bytes directly, then shows how a decompiler like Glass.NET turns them back into the ?, notnull and class? the author wrote.

One type, three annotations, two attributes

Here is a small class that exercises every encoding case:

#nullable enable
using System.Diagnostics.CodeAnalysis;

public sealed class Catalog<T> where T : notnull
{
    public string Name { get; }                                   // non-nullable
    public string? Description { get; set; }                      // nullable
    public Dictionary<string, List<string?>?> Aliases { get; }    // mixed, nested
    public int? LastIndex { get; set; }                           // value type: Nullable<int>

    public Catalog(string name) { Name = name; Aliases = new(); }

    public bool TryFind(string key, [MaybeNullWhen(false)] out T value) { ... }
}

The compiler encodes each reference-type position with one byte:

bytemeaningC# spelling
0oblivious — no annotation informationcode compiled without #nullable enable
1not annotated — non-nullablestring
2annotated — may be nullstring?

It carries those bytes in two attributes that it defines inside your assembly — not referenced from the BCL — as internal sealed types in System.Runtime.CompilerServices, marked with [Microsoft.CodeAnalysis.Embedded] and [CompilerGenerated]:

.class private auto ansi sealed beforefieldinit System.Runtime.CompilerServices.NullableAttribute
       extends [System.Runtime]System.Attribute
{
  .custom instance void Microsoft.CodeAnalysis.EmbeddedAttribute::.ctor() = ( 01 00 00 00 )
  .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 )
  .field public initonly uint8[] NullableFlags
  .method public hidebysig specialname rtspecialname instance void .ctor(uint8)   cil managed { ... }
  .method public hidebysig specialname rtspecialname instance void .ctor(uint8[]) cil managed { ... }
}

.class private auto ansi sealed beforefieldinit System.Runtime.CompilerServices.NullableContextAttribute
       extends [System.Runtime]System.Attribute
{
  .custom instance void Microsoft.CodeAnalysis.EmbeddedAttribute::.ctor() = ( 01 00 00 00 )
  .field public initonly uint8 Flag
  .method public hidebysig specialname rtspecialname instance void .ctor(uint8) cil managed { ... }
}

NullableAttribute goes on a thing with a type — a field, a property, a parameter, a return value, a generic type parameter, a base type — and describes that type. NullableContextAttribute goes on a scope — a type or a method — and sets the default byte for everything inside it that has no NullableAttribute of its own. The two work together to keep the metadata small: the compiler picks the most common byte in a scope as its context and only emits NullableAttribute where a member differs.

Reading the single-byte form

Dump Catalog<T> and the attributes appear on the class itself and on Description:

.class public auto ansi sealed beforefieldinit Catalog`1<T>
       extends [System.Runtime]System.Object
{
  .param type T
    .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( 01 00 01 00 00 )
  .custom instance void System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = ( 01 00 01 00 00 )
  .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( 01 00 00 00 00 )

  .property instance string Name()
  {
    .get instance string Catalog`1::get_Name()
  }

  .property instance string Description()
  {
    .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( 01 00 02 00 00 )
    .get instance string Catalog`1::get_Description()
    .set instance void Catalog`1::set_Description(string)
  }
  ...

The custom-attribute blob format is: a two-byte prolog 01 00, the fixed constructor arguments in order, then a two-byte count of named arguments (00 00 here). So ( 01 00 02 00 00 ) is prolog, the byte 02, no named arguments — [Nullable(2)], the annotated form.

Read the class top to bottom:

  • [NullableContext(1)] on the type: the default for every member of this class is 1, non-nullable. That is why Name carries no attribute at all; its string inherits 1 from the context. The compiler chose 1 because most references in this class are non-nullable; a class full of ? would get [NullableContext(2)] and the non-nullable members would be the ones with explicit attributes.
  • [Nullable(0)] on the type: this describes the class declaration’s own types — its base type and interfaces. System.Object here is oblivious, so 0. This one confuses people; it does not say “this class is oblivious”, it says “the base type reference of this class has no annotation”.
  • [Nullable(1)] on the generic parameter T via .param type T: that is where T : notnull. A notnull constraint has no runtime representation — there is no constraint row for it in the GenericParamConstraint table — so it exists only as this attribute. Drop the attribute and the constraint is gone.
  • [Nullable(2)] on Description: the one member that differs from the context. string?.
  • LastIndex has nothing, and does not need anything: int? is Nullable<int>, which is a different type in the signature blob. Reference nullability attributes have nothing to say about it.

Property accessors get their own NullableContext when they differ from the type’s: get_Description and set_Description are tagged [NullableContext(2)] so that their return value and parameter, which have no attributes of their own, resolve to 2. The property attribute and the accessor contexts always agree; a decompiler can read either.

Reading the array form: flattening a generic type

Aliases is where the single byte stops being enough. Dictionary<string, List<string?>?> has four reference-type positions with different annotations, and the compiler needs to describe all of them. It does that by walking the type tree in preorder — the type itself, then each type argument recursively, left to right — and emitting one byte per node into an array:

preorder walk: node, then type arguments left to rightDictionary<,>1string1List<>?2string?2flattened bytes[ 1, 1, 2, 2 ]attribute blob, uint8[] ctor01 00 prolog04 00 00 00 array length = 401 01 02 02 the flags00 00 no named args
  .property instance class [System.Collections]System.Collections.Generic.Dictionary`2<string, class [System.Collections]System.Collections.Generic.List`1<string>> Aliases()
  {
    .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8[]) = ( 01 00 04 00 00 00 01 01 02 02 00 00 )
    .get instance class ... Catalog`1::get_Aliases()
  }

Look at the property’s declared type in the IL: Dictionary2<string, List1<string>>, no question marks anywhere, because there is nowhere in a signature blob to put one. All four annotations live in the attribute: [1, 1, 2, 2] read in the same preorder as the figure — Dictionary is 1, string is 1, List<...>? is 2, string? is 2.

The uint8[] constructor’s blob is the prolog, a little-endian int32 element count (04 00 00 00), the elements, and the named-argument count. If every byte in the flattened array would be identical, the compiler collapses it to the single-byte constructor — List<string> under a context of 1 needs no attribute at all, and List<string?>? becomes [Nullable(2)], not [Nullable(new byte[] { 2, 2 })]. A decompiler has to handle both constructors and treat the single byte as “this value for every position”.

Value types take a slot but always hold 0. Dictionary<int, string?> flattens to [1, 0, 2]; Dictionary<int, int?> would be [1, 0, 0, 0] — Dictionary 1, int 0, Nullable<> 0, the inner int 0 — which collapses, since all four are not identical, to the array form. Arrays are a node with one child: string?[] is [1, 2] (non-null array of nullable strings) and string[]? is [2, 1]. Tuples flatten through ValueTuple<...> the same way, which is why (string, string?) produces [0, 1, 2].

Out parameters and the analysis attributes

TryFind shows the other half of the story:

  .method public hidebysig instance bool TryFind(string key, [out] !T& 'value') cil managed
  {
    .param [2]
      .custom instance void [System.Runtime]System.Diagnostics.CodeAnalysis.MaybeNullWhenAttribute::.ctor(bool) = ( 01 00 00 00 00 )
    ...
  }

Nothing here is embedded. MaybeNullWhenAttribute is a public type in the BCL; it describes flow — “when the method returns false, do not trust value” — not the declared nullability of the type. The decompiler shows it verbatim, exactly as it shows any other attribute. key carries no NullableAttribute because the method inherits the class’s context of 1, and !T& has nothing because T is constrained notnull at the class level. The split is clean: declared nullability (the ? and the constraints) is in the embedded attributes and must be reconstructed; flow attributes are ordinary metadata and must simply not be hidden.

What the decompiler has to do

Rendering the bytes faithfully is a small algorithm with several places to get wrong:

  1. Find the embedded attributes by name, not by identity. Every assembly defines its own System.Runtime.CompilerServices.NullableAttribute; there is no shared type to compare against. Match on full name, confirm the [Embedded] marker, and treat the definitions themselves as compiler plumbing to hide from the output.
  2. Resolve the effective context for each member. A method’s own NullableContext beats its declaring type’s; a nested type’s beats its outer type’s; a property accessor’s beats the property’s type’s. With no context anywhere up the chain, the default is 0 — oblivious — which is what every pre-C# 8 assembly looks like.
  3. Walk each signature type in preorder, consuming bytes. Take the member’s NullableAttribute if present — a single byte applies to every position; an array yields one byte per node. Otherwise every position takes the context byte. Value types consume a slot and are ignored. Arrays, pointers, byrefs and generic instantiations each contribute their node then recurse.
  4. Spell the result. 2 on a reference type or type parameter appends ?; 1 appends nothing; 0 means the member must be rendered inside a #nullable disable region, or the whole file left without #nullable enable if everything is 0. A 1 on a generic parameter with no other reference constraint becomes where T : notnull; a 2 on a class constraint becomes class?; a 2 on an unconstrained parameter’s usage becomes T?.
  5. Respect [NullablePublicOnly]. When the module carries that marker, the compiler stripped annotations from non-public members; a decompiler must render those as oblivious rather than inferring 1 from a context that, by construction, was never meant to apply to them.
  6. Emit the directive. A file with any 1 or 2 annotations needs #nullable enable at the top, or the recompiled output warns in all the wrong places.

Glass does all six. Open Catalog<T> and the output is the class you started with: string? Description, Dictionary<string, List<string?>?> Aliases, where T : notnull, [MaybeNullWhen(false)] out T value, and a #nullable enable at the top of the file. The two embedded attribute types do not appear in the type tree — you can still reach them by switching to the IL view, where the ( 01 00 04 00 00 00 01 01 02 02 00 00 ) blob is exactly what the figure above shows — and the [Nullable(0)] on the class declaration, which describes an oblivious object base, correctly produces nothing at all.

Why it matters more than it looks

For most compiler-generated metadata, a decompiler that ignores it produces output that is merely uglier. Nullability is different, because ignoring it produces output that is wrong in a way that compiles. Strip the attributes and string? Description becomes string Description; where T : notnull evaporates; #nullable enable is absent, so the rebuilt library is oblivious throughout. A consumer who recompiles against that rebuilt library loses every warning the original author put there, and in the other direction, a TryFind whose out parameter is honestly [MaybeNullWhen(false)] next to a return type that now claims non-null is a contract that was never published.

That is the real test of a decompiler on this feature: not whether the output looks plausible, but whether a library rebuilt from it presents the same nullability contract to its consumers as the original. The information is all there in the metadata — three byte values, a preorder flattening, a context default, and a pair of embedded attribute types — and reading it back is what turns System.String into the string? that was actually written.

Try Nebula.NET

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