The C# record type, originally introduced in C# 9, is particularly useful for immutable data.  It has a very terse syntax to create, and adds useful functionality like value-equality and non-destructive mutation.

The Microsoft documentation explains what you can do with a record but it doesn't show how the record does this.

This post examines the compiler-generated code that is created for the different types of record.  I find being aware of this makes it easier to understand when it is appropriate to use a record and how to extend it when you need more than the default functionality.

What is compiler-generated code?

To make writing C# code more efficient, the compiler allows us to use a terse syntax for common patterns.  Then when compiling, it expands this syntax into the code required to actually run the program.

For example, when you add a property to a class using:

public string MyProperty { get; set; }

the backing field needed will be generated.

In this post I will examine what code a record expands out to, which varies according to the modifiers you use with it.

How to see compiler-generated code

I'm using JetBrains dotPeek to decompile the code.  You can download it for free from here.

By default, it won't show you what the compiler generated code is, so you have to enable the option:

record types

Let's start with the minimal definition of a record. This uses positional-syntax to define the properties:

public record ToDo(string Description, bool IsDone);

Build your solution, then using dotPeek to open the DLL in the bin folder and we get (with attributes removed, for clarity):

  public class ToDo : IEquatable<ToDo>
  {
    private readonly string \u003CDescription\u003Ek__BackingField;
    private readonly bool \u003CIsDone\u003Ek__BackingField;

    public ToDo(string Description, bool IsDone)
    {
      this.\u003CDescription\u003Ek__BackingField = Description;
      this.\u003CIsDone\u003Ek__BackingField = IsDone;
      base.\u002Ector();
    }

    protected virtual Type EqualityContract
    {
      get
      {
        return typeof (ToDo);
      }
    }

    public string Description
    {
      get
      {
        return this.\u003CDescription\u003Ek__BackingField;
      }
      init
      {
        this.\u003CDescription\u003Ek__BackingField = value;
      }
    }

    public bool IsDone
    {
      get
      {
        return this.\u003CIsDone\u003Ek__BackingField;
      }
      init
      {
        this.\u003CIsDone\u003Ek__BackingField = value;
      }
    }

    public override string ToString()
    {
      StringBuilder builder = new StringBuilder();
      builder.Append(nameof (ToDo));
      builder.Append(" { ");
      if (this.PrintMembers(builder))
        builder.Append(' ');
      builder.Append('}');
      return builder.ToString();
    }

    protected virtual bool PrintMembers(StringBuilder builder)
    {
      RuntimeHelpers.EnsureSufficientExecutionStack();
      builder.Append("Description = ");
      builder.Append((object) this.Description);
      builder.Append(", IsDone = ");
      builder.Append(this.IsDone.ToString());
      return true;
    }

    public static bool operator !=(ToDo left, ToDo right)
    {
      return !(left == right);
    }

    public static bool operator ==(ToDo left, ToDo right)
    {
      if ((object) left == (object) right)
        return true;
      return (object) left != null && left.Equals(right);
    }

    public override int GetHashCode()
    {
      return (EqualityComparer<Type>.Default.GetHashCode(this.EqualityContract) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(this.\u003CDescription\u003Ek__BackingField)) * -1521134295 + EqualityComparer<bool>.Default.GetHashCode(this.\u003CIsDone\u003Ek__BackingField);
    }

    public override bool Equals(object obj)
    {
      return this.Equals(obj as ToDo);
    }

    public virtual bool Equals(ToDo other)
    {
      if ((object) this == (object) other)
        return true;
      return (object) other != null && this.EqualityContract == other.EqualityContract && EqualityComparer<string>.Default.Equals(this.\u003CDescription\u003Ek__BackingField, other.\u003CDescription\u003Ek__BackingField) && EqualityComparer<bool>.Default.Equals(this.\u003CIsDone\u003Ek__BackingField, other.\u003CIsDone\u003Ek__BackingField);
    }

    public virtual ToDo \u003CClone\u003E\u0024()
    {
      return new ToDo(this);
    }

    protected ToDo(ToDo original)
    {
      base.\u002Ector();
      this.\u003CDescription\u003Ek__BackingField = original.\u003CDescription\u003Ek__BackingField;
      this.\u003CIsDone\u003Ek__BackingField = original.\u003CIsDone\u003Ek__BackingField;
    }

    public void Deconstruct(out string Description, out bool IsDone)
    {
      Description = this.Description;
      IsDone = this.IsDone;
    }
  }

It's a bit noisy with the compiler generated backing-field names, but we can see:

  • It's a class with read-only properties with backing fields.
  • It has functionality added including equality based on the field values, a human-friendly ToString override, and deconstruction.

So how about:

public readonly record struct ToDoStruct(string Description, bool IsDone);

It looks very similar, except it's now a readonly struct:

  public readonly struct ToDo : IEquatable<ToDoStruct>
  {
    public ToDo(string Description, bool IsDone)

    // rest the same

There is a difference for:

public record struct ToDo(string Description, bool IsDone);

which has read/write properties:

  public struct ToDo : IEquatable<ToDo>
  {
    private string \u003CDescription\u003Ek__BackingField;
    private bool \u003CIsDone\u003Ek__BackingField;

    public ToDo(string Description, bool IsDone)
    {
      this.\u003CDescription\u003Ek__BackingField = Description;
      this.\u003CIsDone\u003Ek__BackingField = IsDone;
    }

    public string Description
    {
      readonly get
      {
        return this.\u003CDescription\u003Ek__BackingField;
      }
      set
      {
        this.\u003CDescription\u003Ek__BackingField = value;
      }
    }

    public bool IsDone
    {
      readonly get
      {
        return this.\u003CIsDone\u003Ek__BackingField;
      }
      set
      {
        this.\u003CIsDone\u003Ek__BackingField = value;
      }
    }

    // rest the same

What about:

public readonly record ToDoStruct(string Description, bool IsDone);

Ok, that was a trick question.  Since public record produces a class with read-only properties anyway, adding the readonly modifier is unnecessary and the compiler doesn't allow it.

You'll frequently need to add some validation or custom logic into a record.  That is fine.  We just implement the bits we need to control and the compiler will fill in the gaps, and still add the augmented behaviours. 

We can also mix and match defining some properties using positional-syntax and others the conventional way (although as noted in the introduction, this style also uses code-generation for the backing field). 

Here we let the compiler generate the Description property, but create IsDone ourselves (in real code, I'd probably either use positional-syntax for all properties or define them all myself, for clarity).  The constructor is added so that some validation can be applied.

public record ToDo(string Description)
{
    public ToDo(string description, bool isDone) : this(description)
            
    {
        if (string.IsNullOrWhiteSpace(description))
            throw new ArgumentException("Description is required", nameof(description));
        IsDone = isDone;
    }
    
    public bool IsDone { get; init; }
}

As you can see (this is the last listing, so have set out in full again) the compiler just generates what is missing, but we still get the advantage of the extra behaviours being added too:

  public class ToDo : IEquatable<ToDo>
  {
    private readonly string \u003CDescription\u003Ek__BackingField;
    private readonly bool \u003CIsDone\u003Ek__BackingField;

    public ToDo(string Description)
    {
      this.\u003CDescription\u003Ek__BackingField = Description;
      base.\u002Ector();
    }

    protected virtual Type EqualityContract
    {
      get
      {
        return typeof (ToDo);
      }
    }

    public string Description
    {
      get
      {
        return this.\u003CDescription\u003Ek__BackingField;
      }
      init
      {
        this.\u003CDescription\u003Ek__BackingField = value;
      }
    }

    public ToDo(string description, bool isDone)
    {
      this.\u002Ector(description);
      if (string.IsNullOrWhiteSpace(description))
        throw new ArgumentException("Description is required", nameof (description));
      this.IsDone = isDone;
    }

    public bool IsDone
    {
      get
      {
        return this.\u003CIsDone\u003Ek__BackingField;
      }
      init
      {
        this.\u003CIsDone\u003Ek__BackingField = value;
      }
    }

    public override string ToString()
    {
      StringBuilder builder = new StringBuilder();
      builder.Append(nameof (ToDo));
      builder.Append(" { ");
      if (this.PrintMembers(builder))
        builder.Append(' ');
      builder.Append('}');
      return builder.ToString();
    }

    protected virtual bool PrintMembers(StringBuilder builder)
    {
      RuntimeHelpers.EnsureSufficientExecutionStack();
      builder.Append("Description = ");
      builder.Append((object) this.Description);
      builder.Append(", IsDone = ");
      builder.Append(this.IsDone.ToString());
      return true;
    }

    public static bool operator !=(ToDo left, ToDo right)
    {
      return !(left == right);
    }

    public static bool operator ==(ToDo left, ToDo right)
    {
      if ((object) left == (object) right)
        return true;
      return (object) left != null && left.Equals(right);
    }

    public override int GetHashCode()
    {
      return (EqualityComparer<Type>.Default.GetHashCode(this.EqualityContract) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(this.\u003CDescription\u003Ek__BackingField)) * -1521134295 + EqualityComparer<bool>.Default.GetHashCode(this.\u003CIsDone\u003Ek__BackingField);
    }

    public override bool Equals(object obj)
    {
      return this.Equals(obj as ToDo);
    }

    public virtual bool Equals(ToDo other)
    {
      if ((object) this == (object) other)
        return true;
      return (object) other != null && this.EqualityContract == other.EqualityContract && EqualityComparer<string>.Default.Equals(this.\u003CDescription\u003Ek__BackingField, other.\u003CDescription\u003Ek__BackingField) && EqualityComparer<bool>.Default.Equals(this.\u003CIsDone\u003Ek__BackingField, other.\u003CIsDone\u003Ek__BackingField);
    }

    public virtual ToDo \u003CClone\u003E\u0024()
    {
      return new ToDo(this);
    }

    protected ToDo(ToDo original)
    {
      base.\u002Ector();
      this.\u003CDescription\u003Ek__BackingField = original.\u003CDescription\u003Ek__BackingField;
      this.\u003CIsDone\u003Ek__BackingField = original.\u003CIsDone\u003Ek__BackingField;
    }

    public void Deconstruct(out string Description)
    {
      Description = this.Description;
    }
  }
}

Code generation is everywhere

Once you start looking, you can find code-generation used in many places by the C# compiler.

For example, what code do you think is generated to allow optional properties, like Category?

public record ToDo(string Description, bool IsDone, string Category = "Default");
    
public class Temp
{
    ToDo myTask = new ToDo("Decompile me", false);
}

Try it and see!

Comments


Comments are closed