Get in Touch with Your Inner Hipster Using C# 9.0 Records

November 19, 2020
Written by
Dustin Ewers
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by
AJ Saulsberry
Contributor
Opinions expressed by Twilio contributors are their own

csharp-9-records.png

If you’ve been a professional developer for more than a year or two, you’ve probably heard some hipster developer rave about functional programming. Much like how music sounds better on vinyl, programming is just better in functional languages. Your IQ jumps ten points if you can write a Fibonacci generator in Lisp, and it jumps twenty points if you can accurately describe what a monad is.

Jokes aside, like craft beer and artisan bacon, the hipsters have a point. As someone who has kids and hobbies, I can’t help you with monads, but learning about less esoteric functional programming concepts will help you write cleaner code with fewer bugs.

Functional programming concepts have been finding their way in our general-purpose programming languages for years, and C# 9 is no exception. In the latest version of C#, Microsoft has introduced some new functional goodies, mostly around immutable programming. In this post, you’re going to learn about C# records, a new feature in C# 9.0.

Review

In C#, along with most other general-purpose, object-oriented languages, class properties, and variables are mutable by default. Mutable just means that you can change properties after they are declared.

Here’s an example that should look familiar:

using System;

var favoriteFood = "tacos";
Console.WriteLine(favoriteFood);
favoriteFood = "fajitas";
Console.WriteLine(favoriteFood);

Mutability is acceptable in many cases, but for those pursuing a more functional style immutable data structures are the norm. For example, in F# variables are immutable by default. If you assign a value to a variable or property, you can’t change it. F# allows for mutable variables, but you have to declare them specifically.

open System

let x = 5
// x <- 30 // This throws a compiler error
let mutable y = 10
y <- 30 // This is fine because you made y mutable

[<EntryPoint>]
let main argv =
    printfn $"Value {x}"
    printfn $"Value {y}"
    0 // return an integer exit code

Immutability is at the core of functional programming because the primary goal of functional programming is to represent your program as a series of pure functions. A pure function is a function with no side effects. It doesn’t mutate any state outside of itself, and it returns the same value for the same set of parameters. Pure functions are easy to test and easy to understand. Even if you aren’t using a functional programming language, you should use pure functions whenever possible.

Functional programming is also more amenable to multithreaded environments. If you are working with an immutable data structure in a pure function you can run that function in any thread without worrying that it will blast the external state in your program. In object oriented programming it’s easy to accidentally mutate shared objects, and if you’re in a multithreaded environment those mutations won’t occur in the same order every time.

Init Only setters

In C#’s quest to become more supportive of functional programming, C# 9.0 has added an init only setter. Init only setters are similar to readonly variables. They can only be set in the constructor of an object or the object’s initializer. This feature allows you to make immutable classes more easily.

Here’s an example of an init only setter in action:

using System;

var book = new Book { 
        Title = "Good Clean Fun",
        Author = "Nick Offerman",
        ISBN = "978-1101984659",
        Pages = 352
    };

// book.Started = DateTime.Now(); // can't init a variable after the constructor

Console.WriteLine(book.Title);

public class Book
{
    public string Title { get; init; }
    public string Author { get; init; }
    public string ISBN { get; init; }
    public int Pages { get; init; }

    public DateTime Started { get; init; }
    public DateTime Finished { get; init; }
}

To run this code, spin up a new .NET console app with one of the following:

Or you can copy the code into an online IDE like .NET Fiddle, which is excellent for playing with new features and trying out code snippets. Be sure to set the Compiler field to ".NET 5".

Introducing Records

Init only setters allow you to add immutability to individual properties, but it would be nice not to type all those property declarations. Instead, let’s define everything in one line of code:

public record Book(string Title, string Author, string ISBN, int Pages);

This is a C# record type. A record type is a class that implements several features by default and is declarable using the terse one-line syntax above. The goal of the record type is to make building immutable reference types a lot easier. If you’ve ever used a case class in Scala, C# records are very similar.

That one line of code creates a class with several traits:

First, the class automatically has a constructor with all of the properties included. These properties are init only.

using System;

var book = new Book("Atomic Habits", "James Clear", "978-0735211292", 320);

Console.WriteLine(book.Author);

// book.Pages = 300; // Compiler Error

public record Book(string Title, string Author, string ISBN, int Pages);

This would be similar to a class that looks like this:

public class Book
{
    public Book(string title, string author, string isbn, int pages)
    {
        Title = title;
        Author = author;
        ISBN = isbn;
        Pages = pages;
    }

    public string Title { get; init; }
    public string Author { get; init; }
    public string ISBN { get; init; }
    public int Pages { get; init; }
}

Record types also implement value equality. Equality in regular classes checks for reference equality. If you create two instances of a class with the same values, those instances are technically not equal. If you did the same with a record type, they would be equal because record types compare the values of each of the members of the class by default.

Here’s an example:

using System;

var bookClass = new BookClass {
    Title = "Good Clean Fun",
    Author = "Nick Offerman",
    ISBN = "978-1101984659",
    Pages = 352
};

var otherBookClass = new BookClass {
    Title = "Good Clean Fun",
    Author = "Nick Offerman",
    ISBN = "978-1101984659",
    Pages = 352
};

Console.WriteLine(bookClass == otherBookClass); // False, because the reference is different

var bookRec = new BookRecord("Atomic Habits", "James Clear", "978-0735211292", 320);
var otherBookRec = new BookRecord("Atomic Habits", "James Clear", "978-0735211292", 320);

Console.WriteLine(bookRec == otherBookRec); // True, because values are the same

public record BookRecord(string Title, string Author, string ISBN, int Pages);

public class BookClass
{
    public string Title { get; init; }
    public string Author { get; init; }
    public string ISBN { get; init; }
    public int Pages { get; init; }
}

You can override the equality in a standard C# class to check for value-based equality instead, but most people don’t. If you want to know how many lines of code you’re saving, here’s a value based equality reference implementation for a truncated version of the Book class:

using System;

var book = new BookClass {
        Title = "Good Clean Fun",
        Author = "Nick Offerman"
};

var otherBook = new BookClass
{
    Title = "Good Clean Fun",
    Author = "Nick Offerman"
};

Console.WriteLine(book == otherBook); // True

public class BookClass: IEquatable<BookClass>
{
    public string Title { get; init; }
    public string Author { get; init; }


    public override bool Equals(object obj)
    {
        return Equals(obj as BookClass);
    }

    public bool Equals(BookClass other)
    {
        // If parameter is null, return false.
        if (ReferenceEquals(other, null))
        {
            return false;
        }

        // Optimization for a common success case.
        if (ReferenceEquals(this, other))
        {
            return true;
        }

        // If run-time types are not exactly the same, return false.
        if (GetType() != other.GetType())
        {
            return false;
        }

        // Return true if the fields match.
        // Note that the base class is not invoked because it is
        // System.Object, which defines Equals as reference equality.
        return (Title == other.Title) && (Author == other.Author);
    }

    public override int GetHashCode()
    {
        return Title.GetHashCode() * 0x00010000 + Author.GetHashCode();
    }

    public static bool operator ==(BookClass lhs, BookClass rhs)
    {
        // Check for null on the left side.
        if (ReferenceEquals(lhs, null))
        {
            if (ReferenceEquals(rhs, null))
            {
                // null == null = true.
                return true;
            }

            // Only the left side is null.
            return false;
        }
        // Equals handles case of null on the right side.
        return lhs.Equals(rhs);
    }

    public static bool operator !=(BookClass lhs, BookClass rhs)
    {
        return !(lhs == rhs);
    }
}

Records also implement a deconstruct method by default so that you can use positional decomposition like a Tuple.

using System;

var book = new Book("Atomic Habits", "James Clear", "978-0735211292", 320);

var (title, author, _, _) = book;
Console.WriteLine($"{title} by {author}");

public record Book(string Title, string Author, string ISBN, int Pages);

The with keyword

When dealing with immutable data types you “change” your values by generating a new object. To make this easier, records support a new operator called with so that you can write with expressions. The with keyword will copy your record and add or modify whatever fields you need to change.

Here’s an example:

using System;

var book = new Book("Atomic Habits", "James Clear", "978-0735211292", 320);

var readBook = AddReadDates(book, new DateTime(2020, 1, 1), new DateTime(2020, 1, 5));

var (title, author, _, _, _, dateFinished) = readBook;
Console.WriteLine($"I read {title} by {author} and finished it on {dateFinished.GetValueOrDefault():D}");

static Book AddReadDates(Book book, DateTime started, DateTime finished)
{
    return book with { Started = started, Finished = finished };
}

public record Book(string Title, string Author, string ISBN, int Pages, DateTime? Started = null, DateTime? Finished = null);

Putting it all together

The features of record types combine well if you want to write terse, functional-flavored C#. This style shines when doing business calculations, which happens a lot in the insurance and finance industries.

Here’s an example program that generates a spread of insurance quotes. It uses record types and static methods. It’s easy to test and understand. Additionally, because everything is immutable, we can run the calculation in parallel, which saves time.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace InsuranceCalc
{
    class Program
    {
        static void Main()
        {
            var customer = new Customer("John Doe", new DateTime(1983, 12, 5));

            var deductibles = Enum.GetValues<Deductible>();
            var plans = Enum.GetValues<PlanLevel>();

            var baseQuote = new Quote(customer, Deductible.Low, PlanLevel.Basic);

            var quotes = deductibles
                    // Cartesian product of all the plan and deductible levels
                    .SelectMany(deductible => plans.Select(plan => new { deductible, plan })
                    // Using that Product to make quotes with all plan combos
                    .Select(dp => (baseQuote with { Deductible = dp.deductible, PlanLevel = dp.plan })))
                    .ToList();

            Parallel.ForEach(quotes, qt =>
            {
                var premium = InsurancePremiumGenerator.GeneratePremium(qt);
                PrintQuotePremium(qt, premium);
            });
        }

        static void PrintQuotePremium(Quote quote, decimal premium)
        {
            var (_, deductible, planLevel) = quote;
            Console.WriteLine($"Premium at the {planLevel} Plan Level and a {deductible} deductible is {premium:C}");
        }
    }

    public record Quote(Customer Customer, Deductible Deductible, PlanLevel PlanLevel);
    public record Customer(string Name, DateTime Birthday);

    public class InsurancePremiumGenerator
    {
        public static decimal GeneratePremium(Quote quote)
        {
            // unload all the variables we need (in one line)
            var ((_, birthday), deductible, planLevel) = quote;
            return GetBaseRate(planLevel) * GetDeductibleFactor(deductible) * GetBirthdayFactor(birthday);
        }

        public static decimal GetBirthdayFactor(DateTime birthDay)
        {
            var age = GetAge(birthDay);

            if (age > 25)
            {
                return 1.25m;
            }
            else if (age < 65)
            {
                return 1m;
            }
            else
            {
                return 1.1m;
            }
        }

        public static int GetAge(DateTime birthday)
        {
            var today = DateTime.Today;
            var age = today.Year - birthday.Year;
            if (birthday.DayOfYear < today.DayOfYear)
            {
                age--;
            }

            return age;
        }

        public static decimal GetDeductibleFactor(Deductible deductible)
        {
            var baseRates = new Dictionary<Deductible, decimal> {
                {Deductible.Low, 1.3m },
                {Deductible.Medium, 1.2m },
                {Deductible.High, 1m }
            };

            return baseRates[deductible];
        }

        public static decimal GetBaseRate(PlanLevel planLevel)
        {
            var baseRates = new Dictionary<PlanLevel, decimal> {
                {PlanLevel.Basic, 1000m },
                {PlanLevel.Standard, 1500m },
                {PlanLevel.GoldPlated, 2000m }
            };

            return baseRates[planLevel];
        }
    }

    public enum Deductible
    {
        Low,
        Medium,
        High
    }

    public enum PlanLevel
    {
        Basic,
        Standard,
        GoldPlated
    }
}

Gotchas

Like every new feature, there are some potential downsides. Record types come packed with assumptions. If one of those assumptions turns out to be invalid, you might have to rewrite your code using regular classes.

For example, if your app uses a record type and you want to use that type with a third party library, that library might not work with records. If you are leaning on some of the assumptions built into record types, like value equality or the with keyword, you’ll have to either create a class to map the values, bring in a bunch of stuff to replicate the record functionality, or edit your code to not lean on the record type assumptions. It’s not a showstopper, but it can be an annoying speed bump.

Another potential downside is that record types don’t force immutability. You can write record types with mutable properties (though you shouldn’t), leading to situations where later developers could add mutable properties and break immutability. While you can never guarantee perfection down the road, you should help people fall into the pit of success whenever possible.

Summary

One of the great things about being a .NET developer is that if you see something cool in another platform, it’s usually only a matter of time before it will show up in C#. As functional paradigms continue to grow in popularity, C# will continue to grab more functional features from other programming languages. Record types give you an ergonomic way to write immutable code in C#. They allow you to save keystrokes and write code that’s safer and easier to test.

It turns out the hipsters were right again. 🙄

Additional resources

Check out the following resources to dive deeper into the topics discussed in this post:

Microsoft Docs - C# 9 Records

Microsoft F# Docs - Why Immutable?

Microsoft Docs - Value Equality Implementation

Dustin Ewers is a software developer hailing from Southern Wisconsin. He helps people build better software. Dustin has been building software for over ten years, specializing in Microsoft technologies. He is an active member of the technical community, speaking at user groups and conferences in and around Wisconsin. He writes about technology at https://www.dustinewers.com. Follow him on Twitter at @DustinJEwers.