I previously wrote on the difficulties of abstracting ID types in C#. Good news, C# 9 record types greatly simplify this design decision.

Record types bring the functional definition of data to C#. That is, they are immutable with value-based equality semantics.

In other words

1
2
3
4
5
6
record Person{
    public string First {get; }
    public string Last {get; }
}

bool equal = new Person { First = "Bob", Last = "Person"} == new Person {First = "Bob", Last = "Person"}; // true

And

1
2
3
4
Person original = new Person { First = "Bob", Last = "Person"};
Person updated = original with { First = "Robert"};

bool equal = original == updated; // false;

This makes id types pretty trivial

1
2
3
4
5
6
7
8
9
record PersonId : IComparable<PersonId>{
    private Guid _id {get; init;}

    public static readonly PersonId Default = new PersonId { _id = Guid.Empty };

    public int CompareTo(PersonId other){
        return _id.CompareTo(other._id);
    }
}

All we have to do is implement CompareTo and expose a default value, everything else is automatic.

But wait, aren’t structs suppose to have value-semantics? Ids wouldn’t be too big for a value-type either (records are reference types).
Well… structs have value semantics, but only implement .Equal(). Any operators like == and != have to custom implemented, which means you also have to implement GetHashCode. It ends up being a lot of boiler-plate code.

Inheritance and Covariance

C# 9 also introduces covariant overrides, which make inter-operable ID types relatively simple too.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
    record BaseId : IComparable<BaseId>{

        protected Guid _id {get; init;};

        public virtual BaseId Default(){
            return new BaseId{ _id = default()};
        }

        public virtual BaseId New(){
            //...exposing new makes it hard to use sequential id types like int, but you may not care
        }

        public int CompareTo(BaseId other){
            //...
        }

    }

    record ChildId : BaseId, IComparable<ChildId>{
        // Notice how the override returns the child type. That wasn't possible in C# 8.
        public override ChildId Default(){
            return new ChildId{ _id = default()};
        }

        public override ChildId New(){
            return new ChildId{_id = base.New()._id};
        }

        public int CompareTo(ChildId other){
            //...
        }
    }

Closing thoughts

I normally don’t spend much time ogling over language features, but I gotta say that C# 9 introduces some powerful changes. Just these two improvements allow me to much better encode my design intent. At last I will have practical ID abstractions!