Skip to content
wannacode

record, switch and patterns

In short: A record is a data type for which C# writes the constructor, value equality and ToString, and a switch with patterns chooses by type and properties.

Problem

The seat is taken, but Dictionary cannot find it

Movie theater reservations: the key is the seat (row and number), the value is the person's name.

Seat
class Seat
{
private int row;
private int number;
public Seat(int row, int number)
{
this.row = row;
this.number = number;
}
}
Dictionary<Seat, string> reservations = new Dictionary<Seat, string>();
reservations.Add(new Seat(5, 12), "Maria");
Console.WriteLine(reservations.GetValueOrDefault(new Seat(5, 12), "free"));
Console.WriteLine(reservations.ContainsKey(new Seat(5, 12)));
Output
free
False

Maria is in row 5, seat 12, but GetValueOrDefault(key, fallback), which gives the value or the fallback if the key is missing, says "free". You remember why from the lesson on hashing: new Seat(5, 12) written twice is two different objects, and Seat does not say when they are equal. The fix is Equals and GetHashCode, plus ToString for printing, and properties for reading. About 30 lines for a class that only carries two numbers.

More precisely, if you are curious

The indexer reservations[...] with a key that is missing does not give a fallback, it throws an exception:

try
{
Console.WriteLine(reservations[new Seat(5, 12)]);
}
catch (KeyNotFoundException e)
{
Console.WriteLine("KeyNotFoundException: " + e.Message);
}
Output
KeyNotFoundException: The given key 'Seat' was not present in the dictionary.

The idea

record: a class for data

Since C# 9 the same thing takes one line:
record Seat(int Row, int Number);
Dictionary<Seat, string> reservations = new Dictionary<Seat, string>();
reservations.Add(new Seat(5, 12), "Maria");
Console.WriteLine(reservations.GetValueOrDefault(new Seat(5, 12), "free"));
Console.WriteLine(reservations.ContainsKey(new Seat(5, 12)));
Seat seat = new Seat(5, 12);
Console.WriteLine(seat);
Console.WriteLine("Row " + seat.Row + ", seat " + seat.Number);
Output
Maria
True
Seat { Row = 5, Number = 12 }
Row 5, seat 12

A record is a class whose job is to carry data. In the parentheses are its positional parameters, and C# writes for you:

  • a property for each parameter, with the same name: Row and Number. It is set only at creation;
  • a constructor with all the parameters;
  • Equals and GetHashCode, which compare the properties. That is why Dictionary now finds Maria. The operators == and != also compare the properties;
  • ToString in the form Seat { Row = 5, Number = 12 }.

An object of a record does not change after it is created. A new object with a change comes from with (see "More precisely").

Dictionary<Seat, string> reservations = new Dictionary<Seat, string>();
reservations.Add(new Seat(5, 12), "Maria");
Console.WriteLine(reservations.GetValueOrDefault(new Seat(5, 12), "free"));
Console.WriteLine(reservations.ContainsKey(new Seat(5, 12)));
Seat seat = new Seat(5, 12);
Console.WriteLine(seat);
Console.WriteLine("Row " + seat.Row + ", seat " + seat.Number);
Step by step, in words
  1. An empty map: the key is a theater seat, the value is a name.
  2. Add adds the key Seat(5, 12) with the value "Maria".
  3. GetValueOrDefault receives a new Seat object. It is a different object in memory, not the key from Add.
  4. The GetHashCode of a record is computed from the properties, so it is the same: the lookup goes to the same place.
  5. The Equals of a record compares property by property: row 5 and 5, seat 12 and 12. They are equal, and GetValueOrDefault returns "Maria".
  6. ContainsKey does the same with yet another new Seat: True.
  7. The record also writes ToString and the properties Row and Number by itself.
More precisely, if you are curious
  • with makes a copy of the object and changes only the properties listed. The old object stays the same. A positional record can also be taken apart into separate variables:
Seat seat = new Seat(5, 12);
Seat next = seat with { Number = 13 };
Console.WriteLine(next);
Console.WriteLine(seat);
var (row, number) = next;
Console.WriteLine($"Row {row}, seat {number}");
Output
Seat { Row = 5, Number = 13 }
Seat { Row = 5, Number = 12 }
Row 5, seat 13
  • An attempt to change a property does not compile:
Seat seat = new Seat(5, 12);
seat.Row = 6;
Error
error CS8852: Init-only property or indexer 'Seat.Row' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor.
  • A record can have its own methods, properties and fields besides the positional ones. They also count in Equals. It can extend another record (we use that in the card about payments), but not a plain class, and it can implement interfaces. A sealed record cannot be extended.
  • A record is a class: the variable points to the object. record struct Point(int X, int Y); is a value type, like int. It also compares by its fields.
  • The immutability is only one level deep. If a property is a List, it always points to the same list, but the list itself can change. And Equals compares the lists as arrows, not by contents:
record Cart(List<string> Items);
Cart cart = new Cart(new List<string> { "coffee" });
cart.Items.Add("honey");
Console.WriteLine(string.Join(", ", cart.Items));
Console.WriteLine(cart == new Cart(new List<string> { "coffee", "honey" }));
Output
coffee, honey
False
  • In the "Collections" module you wrote Equals and GetHashCode by hand. With record you already have them. If you need a different rule for equality, you can still write your own.

The idea

Checks and methods in a record

A price cannot be negative. The positional form record Price(decimal Amount, string Currency); has no place for a check. So here the properties and the constructor are written by hand, and record still gives Equals, GetHashCode and ToString.
record Price
{
public decimal Amount { get; }
public string Currency { get; }
public Price(decimal amount, string currency)
{
if (amount < 0)
{
throw new ArgumentException("The price cannot be negative: " + amount);
}
Amount = amount;
Currency = currency;
}
public Price WithDiscount(decimal percent)
{
return new Price(Math.Round(Amount * (1 - percent / 100), 2), Currency);
}
}
Price coffee = new Price(18.50m, "EUR");
Price sale = coffee.WithDiscount(20);
Console.WriteLine(coffee);
Console.WriteLine(sale);
try
{
new Price(-5, "EUR");
}
catch (ArgumentException e)
{
Console.WriteLine(e.Message);
}
Output
Price { Amount = 18.50, Currency = EUR }
Price { Amount = 14.80, Currency = EUR }
The price cannot be negative: -5
  • The check comes before the properties are set. If the constructor throws an exception, the object is not created.
  • WithDiscount does not change the price. It returns a new Price. That is how you work with objects that do not change: coffee stays at €18.50.
  • Math.Round(..., 2) rounds to the cent. decimal keeps the digits after the decimal point: without rounding, 18.50 times 0.80 is 14.800.
  • The properties here have only get. So with cannot change them and cannot get around the check.

Try it

Are they equal?

Seat is a record, as in the card before this one.

What will the program print?

Seat a = new Seat(5, 12);
Seat b = new Seat(5, 12);
Console.WriteLine(a == b);
Console.WriteLine(a.GetHashCode() == b.GetHashCode());
Console.WriteLine(ReferenceEquals(a, b));
Answer

Try it

The delivery comes out expensive

Delivery to an office is €4.50, but the program says €6.90. Which line is the bug on?

Pick the line with the bug.

The idea

A switch that returns a value

Since C# 8 a switch can be an expression: it gives a value that you store in a variable or return.
string method = "office";
decimal price = method switch
{
"office" => 4.50m,
"courier" or "address" => 6.90m,
"store" => 0,
_ => throw new ArgumentException("Unknown delivery method: " + method),
};
Console.WriteLine("Delivery: €" + price);
Output
Delivery: €4.50
  • The value being checked comes before switch. Each case is pattern => value. There is no fall-through, and no break is written.
  • Several values in one case are joined with or: "courier" or "address".
  • _ catches everything else, like default. In that case you can throw an exception.
  • The expression has to give a value for every input. With text the inputs are endless, so _ has to be there. Without it C# does not stop the compilation, it only warns:
static decimal Price(string method)
{
return method switch
{
"office" => 4.50m,
"courier" => 6.90m,
};
}
Warning
warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern '""' is not covered.

And the program stops only when an input with no case comes along, Price("post"):

Error
Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException: Non-exhaustive switch expression failed to match its input.
Unmatched value was post.

If the case also depends on another condition, it is added with when:

static decimal Delivery(string method, double weightKg)
{
return method switch
{
"office" => 4.50m,
"courier" when weightKg > 10 => 6.90m + 3,
"courier" => 6.90m,
_ => 0,
};
}
Console.WriteLine(Delivery("courier", 4));
Console.WriteLine(Delivery("courier", 12));
Output
6.90
9.90
More precisely, if you are curious
  • Each case is only one expression: there is no block of several lines here. If the calculation is long, put it in a method and call it from the case.
  • The old switch with case "office": ... break; still works, and you will see it in old code. In it too a case with code does not fall through (the card before).

The idea

A family of types

The shop accepts three payment methods: card, bank transfer and cash on delivery. There are no others. Each method is its own record, a subtype of a common abstract record:

abstract record Payment;
sealed record CardPayment(string CardNumber) : Payment;
sealed record BankTransfer(string Iban) : Payment;
sealed record CashOnDelivery() : Payment;

abstract record Payment; is the common type: you cannot create an object of it, only of the subtypes. : Payment after a subtype says "this is a kind of Payment". The body is empty, so the declaration ends with ;. The subtypes are records, because each payment method is just data: a card number, an IBAN or nothing.

sealed in C# means something else: the class cannot be extended. Here each payment method is sealed, so that there is no "kind of card". But the family itself cannot be closed: there is no way in C# to say "only these three extend Payment". A fourth kind compiles with no error:

sealed record Voucher(string Code) : Payment;

How to guard against it is in the next card.

See it

Patterns in switch

The fee depends on the payment method. A switch can also check the type of the object:
static decimal Fee(Payment payment, decimal amount)
{
return payment switch
{
CardPayment => Math.Round(amount * 0.015m, 2),
BankTransfer => 0.50m,
CashOnDelivery => 2.00m,
_ => throw new ArgumentException("Unknown payment: " + payment),
};
}
Console.WriteLine(Fee(new CardPayment("4111222233334444"), 200));
Console.WriteLine(Fee(new BankTransfer("BG80BNBG96611020345678"), 200));
Console.WriteLine(Fee(new CashOnDelivery(), 200));
Output
3.00
0.50
2.00

CardPayment => ... means "if the object is a CardPayment". If you also need the object itself, give it a name: CardPayment card => .... This is is with a pattern from the "Inheritance and polymorphism" module, but for many types at once.

The _ at the end is required. The compiler does not know all the subtypes of Payment: anyone can add a new one. Without _ it only warns:

static decimal Fee(Payment payment, decimal amount)
{
return payment switch
{
CardPayment => Math.Round(amount * 0.015m, 2),
BankTransfer => 0.50m,
CashOnDelivery => 2.00m,
};
}
Warning
warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern '_' is not covered.

Then a fourth kind stops the program only at run time, with a SwitchExpressionException. _ with throw at least stops it with a clear message that says which kind is missing:

try
{
Fee(new Voucher("SALE10"), 200);
}
catch (ArgumentException e)
{
Console.WriteLine(e.Message);
}
Output
Unknown payment: Voucher { Code = SALE10 }

A positional pattern takes the properties out directly, in the order of the positional parameters, and when adds a condition to the case:

static string Describe(Payment payment)
{
return payment switch
{
CardPayment(var number) => "card ****" + number.Substring(number.Length - 4),
BankTransfer(var iban) when iban.StartsWith("BG") => "transfer from a Bulgarian account",
BankTransfer => "transfer from abroad",
CashOnDelivery => "cash on delivery",
_ => throw new ArgumentException("Unknown payment: " + payment),
};
}
Console.WriteLine(Describe(new CardPayment("4111222233334444")));
Console.WriteLine(Describe(new BankTransfer("BG80BNBG96611020345678")));
Console.WriteLine(Describe(new BankTransfer("DE89370400440532013000")));
Console.WriteLine(Describe(new CashOnDelivery()));
Output
card ****4444
transfer from a Bulgarian account
transfer from abroad
cash on delivery
More precisely, if you are curious
  • The positional pattern also works with is. A property can also be named: CardPayment { CardNumber: var digits }. That is a property pattern, and it works with any class, not only with record:
Payment payment = new CardPayment("4111222233334444");
if (payment is CardPayment(var number))
{
Console.WriteLine("The card ends in " + number.Substring(12));
}
if (payment is CardPayment { CardNumber: var digits })
{
Console.WriteLine("And by property name: " + digits.Substring(12));
}
Output
The card ends in 4444
And by property name: 4444
  • If the object is null, no type matches and it gets to _. If null is a meaningful input, add null => ... before _.
  • Patterns can be nested: Order(Customer(var name), var total).

Try it

Which case wins?

The cases are checked from top to bottom. The first one that matches wins.

What will the program print?

static string Label(Payment payment, decimal amount)
{
return payment switch
{
CashOnDelivery when amount > 500 => "cash on delivery: not accepted over €500",
CashOnDelivery => "cash on delivery",
CardPayment when amount > 1000 => "card: confirmation needed",
CardPayment => "card",
BankTransfer => "transfer",
_ => throw new ArgumentException("Unknown payment: " + payment),
};
}
Console.WriteLine(Label(new CashOnDelivery(), 650));
Console.WriteLine(Label(new CashOnDelivery(), 80));
Console.WriteLine(Label(new CardPayment("4111222233334444"), 650));
Answer
More precisely, if you are curious

The case with when has to come before the general one for the same type. The other way around does not compile, because the general case catches everything and the one with when could never be reached:

CashOnDelivery => "cash on delivery",
CashOnDelivery when amount > 500 => "cash on delivery: not accepted over €500",
Error
error CS8510: The pattern is unreachable. It has already been handled by a previous arm of the switch expression or it is impossible to match.

The idea

Raw strings and var

Text on several lines, like a receipt or JSON, is hard to read with " and \n on every line. Since C# 11 there is the raw string literal between """ and """:

string receipt = """
The Corner Shop
Coffee beans €18.50
Honey €12.00
Total: €30.50
""";
Console.WriteLine(receipt);
string name = "Maria";
decimal total = 75.5m;
string json = $$"""
{"customer": "{{name}}", "total": {{total:F2}}}
""";
Console.WriteLine(json);
Output
The Corner Shop
Coffee beans €18.50
Honey €12.00
Total: €30.50
{"customer": "Maria", "total": 75.50}
  • After the opening """ the text starts on a new line, and the closing """ are on their own line. The newline before them is not part of the text, which is why it is WriteLine.
  • The common indentation on the left is removed. The measure is the indentation of the closing """.
  • Quotes inside are not escaped.
  • A $ in front of the string fills in values in curly braces. Here the JSON itself has curly braces, so there are two $$ in front, and the values are in double braces: {{name}} and {{total:F2}}.

And a reminder from "Language basics": var lets the compiler infer the type. With the long types from collections it saves the most:

var prices = new Dictionary<string, decimal>();
prices["coffee"] = 18.50m;
prices["honey"] = 12.00m;
foreach (var pair in prices)
{
Console.WriteLine(pair.Key + ": €" + pair.Value);
}
Output
coffee: €18.50
honey: €12.00

var is only for local variables with a value. The type is set once and does not change. Fields, parameters and var x = null; are not allowed.

Exercise

Modern C#

Solve the tasks in your editor.

1. Shapes. An abstract record Shape with three sealed record subtypes: Circle(double Radius), Rectangle(double Width, double Height) and Triangle(double Base, double Height). Write Area(Shape) with a switch expression and positional patterns, with a _ that throws ArgumentException. Also write Name(Shape), which says "square" for a rectangle with equal sides (with when). Print a circle with radius 1, a 2 by 3 rectangle, a 4 by 4 square, a triangle with base 3 and height 5, and the total area.
Output
circle: 3.14
rectangle: 6.00
square: 16.00
triangle: 7.50
Total: 32.64
2. Delivery price. An abstract record Delivery with ToOffice(string Office), ToAddress(string City, decimal WeightKg) and Pickup(). The price: to an office €4.50; to an address €5.90 in Sofia and €6.90 elsewhere, plus €1 for every kilogram over 5; from the store €0. Print each delivery (with the ToString of the record) and its price.
Output
ToOffice { Office = Varna Center } -> €4.50
ToAddress { City = Sofia, WeightKg = 2 } -> €5.90
ToAddress { City = Varna, WeightKg = 8 } -> €9.90
Pickup { } -> €0.00
3. A point as a key. The courier passes through points on the map: (2, 3), (5, 1), (2, 3), (0, 0), (5, 1), (2, 3). Make record struct Point(int X, int Y) and count how many times it has been at each point in a Dictionary<Point, int> with visits.GetValueOrDefault(p) + 1. At the end, look up new Point(2, 3) in the dictionary.
Output
Point { X = 2, Y = 3 }: 3
Point { X = 5, Y = 1 }: 2
Point { X = 0, Y = 0 }: 1
Visits to (2, 3): 3

More tasks

  • A student as a record. A record Student with properties Name and Grade and a constructor that rejects a grade outside 2 to 6, like Price. Sort a list of students with OrderBy(s => s.Grade).
  • Day of the week. Rewrite the "day of the week" task from the lesson "Conditions" with a switch expression: 1 or 2 or 3 or 4 or 5 => "weekday".
  • A letter. With a raw string and $, make a letter confirming a reservation: name, date, number of guests and price in euros.

Test

Check yourself

1. What do you get ready-made with record Seat(int Row, int Number);?
2. Why does a record work as a key in a Dictionary, while a plain class with nothing added does not?
3. What is the advantage of the switch expression over the old switch?
4. Payment is an abstract record with three subtypes. Why does a switch on Payment have _ at the end?
5. What does BankTransfer(var iban) when iban.StartsWith("BG") => ... do?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·