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


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:

```csharp
#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:

| byte | meaning | C# spelling |
|---|---|---|
| `0` | oblivious — no annotation information | code compiled without `#nullable enable` |
| `1` | not annotated — non-nullable | `string` |
| `2` | annotated — may be null | `string?` |

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:

<figure class="my-8"><svg viewBox="0 0 720 230" role="img" aria-label="The generic type Dictionary of string and nullable List of nullable string drawn as a tree. The root node Dictionary has byte 1. Its first child string has byte 1. Its second child List-question-mark has byte 2, and that node's child string-question-mark has byte 2. Below the tree, the preorder traversal order is shown as the byte array one, one, two, two, and the resulting attribute blob is zero-one zero-zero, then zero-four zero-zero zero-zero zero-zero as the array length, then zero-one zero-one zero-two zero-two as the bytes, then zero-zero zero-zero for no named arguments." style="width:100%;height:auto"><defs><style>.t{font:600 12px system-ui;fill:#1547e0}.x{font:11px system-ui;fill:#475569}.m{font:600 11px ui-monospace,monospace;fill:#0f172a}.n1{fill:#ecfdf5;stroke:#6ee7b7;stroke-width:1.5}.n2{fill:#fee2e2;stroke:#fca5a5;stroke-width:1.5}.edge{stroke:#94a3b8;stroke-width:1.5}.b{font:700 12px ui-monospace,monospace}.blob{fill:#f8fafc;stroke:#e2e8f0}</style></defs><text class="t" x="16" y="22">preorder walk: node, then type arguments left to right</text><line class="edge" x1="200" y1="56" x2="110" y2="104"/><line class="edge" x1="200" y1="56" x2="300" y2="104"/><line class="edge" x1="300" y1="118" x2="300" y2="158"/><rect class="n1" x="130" y="36" width="140" height="28" rx="6"/><text class="m" x="140" y="54">Dictionary&lt;,&gt;</text><text class="b" x="276" y="54" fill="#16a34a">1</text><rect class="n1" x="50" y="96" width="110" height="28" rx="6"/><text class="m" x="60" y="114">string</text><text class="b" x="146" y="114" fill="#16a34a">1</text><rect class="n2" x="240" y="96" width="120" height="28" rx="6"/><text class="m" x="250" y="114">List&lt;&gt;?</text><text class="b" x="336" y="114" fill="#dc2626">2</text><rect class="n2" x="240" y="152" width="120" height="28" rx="6"/><text class="m" x="250" y="170">string?</text><text class="b" x="336" y="170" fill="#dc2626">2</text><text class="t" x="420" y="56">flattened bytes</text><rect class="blob" x="420" y="66" width="284" height="30" rx="6"/><text class="m" x="432" y="86">[ 1, 1, 2, 2 ]</text><text class="t" x="420" y="128">attribute blob, uint8[] ctor</text><rect class="blob" x="420" y="138" width="284" height="76" rx="6"/><text class="m" x="432" y="158">01 00            prolog</text><text class="m" x="432" y="176">04 00 00 00      array length = 4</text><text class="m" x="432" y="194">01 01 02 02      the flags</text><text class="m" x="432" y="212">00 00            no named args</text></svg></figure>

```
  .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: `Dictionary`2<string, List`1<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.

