Records and Pattern Matching
C# record types provide value-based equality and immutability; switch expressions with property patterns replace TypeScript discriminated unions and complex conditionals.
Introduction
In this lesson, you'll learn about records and pattern matching in C#. Coming from TypeScript, you already have a foundation for understanding this concept. We'll build on that knowledge while highlighting the key differences.
In TypeScript, you're familiar with c# record types provide value-based equality and immutability; switch expressions with property patterns replace typescript discriminated unions and complex conditionals..
C# has its own approach to c# record types provide value-based equality and immutability; switch expressions with property patterns replace typescript discriminated unions and complex conditionals., which we'll explore step by step.
The C# Way
Let's see how C# handles this concept. Here's a typical example:
// record: immutable, value-based equality, with-expression
record Point(double X, double Y);
record Circle(double Radius) : Shape;
record Rect(double Width, double Height) : Shape;
record Triangle(double Base, double Height) : Shape;
abstract record Shape;
// Switch expression with property patterns
static double Area(Shape s) => s switch {
Circle { Radius: var r } => Math.PI * r * r,
Rect { Width: var w, Height: var h } => w * h,
Triangle { Base: var b, Height: var h } => 0.5 * b * h,
_ => throw new ArgumentException("Unknown shape")
};
// Value equality — records compare by properties
var p1 = new Point(1, 2);
var p2 = new Point(1, 2);
Console.WriteLine(p1 == p2); // True (value equality!)
Console.WriteLine(p1.Equals(p2)); // True
// with-expression: non-destructive mutation
var p3 = p1 with { X = 10 }; // new Point(10, 2)
Console.WriteLine(p3); // Point { X = 10, Y = 2 }Comparing to TypeScript
Here's how you might have written similar code in TypeScript:
// TypeScript discriminated union
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.radius ** 2;
case "rect": return s.width * s.height;
case "triangle": return 0.5 * s.base * s.height;
}
}
// Immutable data objects
type Point = { readonly x: number; readonly y: number };
const p: Point = { x: 1, y: 2 };
const p2 = { ...p, x: 10 }; // "update" via spread
// Equality is reference-based in JS
console.log({ x: 1 } === { x: 1 }); // falseYou may be used to different syntax or behavior.
record types have value-based equality by default (== compares properties) — unlike classes where == is reference equality
You may be used to different syntax or behavior.
with expression creates a copy with specified properties changed — equivalent to object spread in TypeScript
You may be used to different syntax or behavior.
switch expression (not statement) with => returns a value; exhaustiveness checked by compiler
You may be used to different syntax or behavior.
Property patterns ({ Radius: var r }) extract and bind values in one step
You may be used to different syntax or behavior.
TypeScript discriminated unions use a 'kind' tag; C# pattern matching works on the actual type
Step-by-Step Breakdown
1. Record Types
Records are classes with automatically generated constructor, value equality, and ToString. Use for immutable data transfer objects.
type Point = { readonly x: number; readonly y: number };
const p = { x: 1, y: 2 };record Point(double X, double Y);
// Gets: constructor, == by value, ToString, Deconstruct
var p = new Point(1, 2);
Console.WriteLine(p); // Point { X = 1, Y = 2 }2. with Expression
'with' creates a new record with specified properties changed, leaving others unchanged — equivalent to TypeScript spread.
const p2 = { ...p, x: 10 };var p2 = p with { X = 10 };
Console.WriteLine(p2); // Point { X = 10, Y = 2 }
Console.WriteLine(p); // original unchanged3. Switch Expression
switch expression returns a value and must be exhaustive. Pattern arms use property patterns, type patterns, and when guards.
switch (s.kind) {
case 'circle': return Math.PI * s.radius**2;
...default: exhaustive check
}double area = shape switch {
Circle { Radius: var r } => Math.PI * r * r,
Rect { Width: var w, Height: var h } => w * h,
_ => 0,
};
// Compiler warns if all cases aren't covered4. Type and Guard Patterns
Combine type patterns, property patterns, and 'when' guards for expressive control flow.
if (x instanceof Error && x.code === 404) { ... }string Describe(object obj) => obj switch {
string s when s.Length == 0 => "empty string",
string s => $"string: {s}",
int n when n < 0 => "negative",
int n => $"int: {n}",
null => "null",
_ => "other",
};Common Mistakes
When coming from TypeScript, developers often make these mistakes:
- record types have value-based equality by default (== compares properties) — unlike classes where == is reference equality
- with expression creates a copy with specified properties changed — equivalent to object spread in TypeScript
- switch expression (not statement) with => returns a value; exhaustiveness checked by compiler
Key Takeaways
- record Point(X, Y) — auto-generates constructor, value equality, ToString, with support
- p with { X = 10 } creates a new record with X changed — non-destructive update like spread
- switch expression with property patterns is exhaustive and returns a value
- Type equality for records is by property values (==); for classes it's reference equality