From d853dfd6ea99ba280582a740ce581ca4320e11cd Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Wed, 15 Apr 2026 15:03:29 -0400 Subject: [PATCH 1/6] Build code sample. --- .../snippets/choosing-types/Program.cs | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/csharp/fundamentals/tutorials/snippets/choosing-types/Program.cs diff --git a/docs/csharp/fundamentals/tutorials/snippets/choosing-types/Program.cs b/docs/csharp/fundamentals/tutorials/snippets/choosing-types/Program.cs new file mode 100644 index 0000000000000..4847d70c4631a --- /dev/null +++ b/docs/csharp/fundamentals/tutorials/snippets/choosing-types/Program.cs @@ -0,0 +1,112 @@ +#!/usr/bin/env dotnet + +// +(int TotalOrders, decimal Revenue) GetDailySummary(int orders, decimal revenue) + => (orders, revenue); + +Console.WriteLine("=== Tuple: daily summary ==="); +var summary = GetDailySummary(42, 1234.50m); +Console.WriteLine($"Orders: {summary.TotalOrders}, Revenue: {summary.Revenue:F2}"); + +var (orders, revenue) = summary; +Console.WriteLine($"Deconstructed: {orders} orders, {revenue:F2}"); +// + +// +Console.WriteLine("\n=== Record class: MenuItem ==="); +var latte = new MenuItem("Latte", 4.50m, "Contains dairy"); +var latte2 = new MenuItem("Latte", 4.50m, "Contains dairy"); +var seasonal = latte with { Name = "Pumpkin Spice Latte", Price = 5.25m }; + +Console.WriteLine(latte); +Console.WriteLine(seasonal); +Console.WriteLine($"Same reference (latte vs latte2): {ReferenceEquals(latte, latte2)}"); +Console.WriteLine($"Value equal (latte vs latte2): {latte == latte2}"); +Console.WriteLine($"Value equal (latte vs seasonal): {latte == seasonal}"); +// + +// +Console.WriteLine("\n=== Record struct: Measurement ==="); +var temp = new Measurement(72.5, "°F"); +var copy = temp; + +copy = copy with { Value = 23.0, Unit = "°C" }; + +Console.WriteLine($"Original: {temp.Value}{temp.Unit}"); +Console.WriteLine($"Copy (converted): {copy.Value}{copy.Unit}"); +// + +// +Console.WriteLine("\n=== Class: Order ==="); +var order = new Order(); +order.AddItem("Latte", 4.50m); +order.AddItem("Croissant", 3.25m); +order.Status = "Ready"; + +Console.WriteLine(order); +// + +// +static decimal Checkout(decimal total, IDiscountPolicy policy) => policy.Apply(total); + +Console.WriteLine("\n=== Interface: discount policy ==="); +decimal subtotal = 12.00m; +Console.WriteLine($"Happy hour (20% off): {Checkout(subtotal, new HappyHourDiscount()):F2}"); +Console.WriteLine($"Loyalty ($1 off): {Checkout(subtotal, new LoyaltyDiscount()):F2}"); +// + +// +Console.WriteLine("\n=== Combined coffee shop scenario ==="); +var order2 = new Order(); +order2.AddItem("Espresso", 3.00m); +order2.Status = "Complete"; + +var drinkOfTheDay = new MenuItem("Cold Brew", 4.00m, "Vegan") with { Price = 3.50m }; +var brewTemp = new Measurement(38.0, "°F"); +var daySummary = GetDailySummary(120, 525.75m); + +Console.WriteLine($"Today's special: {drinkOfTheDay.Name} at {drinkOfTheDay.Price:F2}"); +Console.WriteLine($"Brew temp: {brewTemp.Value}{brewTemp.Unit}"); +Console.WriteLine($"Day total: {daySummary.TotalOrders} orders / {daySummary.Revenue:F2}"); +Console.WriteLine(order2); +// + +// +record class MenuItem(string Name, decimal Price, string NutritionalNote); +// + +// +record struct Measurement(double Value, string Unit); +// + +// +class Order +{ + public string Status { get; set; } = "Pending"; + private readonly List<(string Name, decimal Price)> _items = []; + + public void AddItem(string name, decimal price) => _items.Add((name, price)); + + public decimal Total => _items.Sum(i => i.Price); + + public override string ToString() => + $"Order [{Status}]: {string.Join(", ", _items.Select(i => i.Name))} - Total: {Total:F2}"; +} +// + +// +interface IDiscountPolicy +{ + decimal Apply(decimal total); +} + +class HappyHourDiscount : IDiscountPolicy +{ + public decimal Apply(decimal total) => total * 0.80m; +} + +class LoyaltyDiscount : IDiscountPolicy +{ + public decimal Apply(decimal total) => total - 1.00m; +} +// From 713c5d8025ebebcbe6c62937592574218162d11d Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Wed, 15 Apr 2026 15:05:49 -0400 Subject: [PATCH 2/6] add the article. --- .../fundamentals/tutorials/choosing-types.md | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 docs/csharp/fundamentals/tutorials/choosing-types.md diff --git a/docs/csharp/fundamentals/tutorials/choosing-types.md b/docs/csharp/fundamentals/tutorials/choosing-types.md new file mode 100644 index 0000000000000..588cde468a049 --- /dev/null +++ b/docs/csharp/fundamentals/tutorials/choosing-types.md @@ -0,0 +1,147 @@ +--- +title: "Tutorial: Choose between tuples, records, structs, and classes" +description: "Learn when to use tuples, record classes, record structs, classes, and interfaces in C# by building a coffee shop example that highlights each type's strengths." +ms.date: 04/15/2026 +ms.topic: tutorial +ai-usage: ai-assisted +--- + +# Tutorial: Choose between tuples, records, structs, and classes + +> [!TIP] +> This article is part of the **Fundamentals** section, written for developers who know at least one programming language and are learning C#. If you're new to programming, start with [Get started](../../tour-of-csharp/index.yml). For a quick reference table, see [Choose which kind of type](../types/index.md#choose-which-kind-of-type). + +C# gives you several ways to group data: tuples, record classes, record structs, classes, and interfaces. Picking the right one depends on whether you need naming, equality, mutability, or polymorphism. In this tutorial, you build a small coffee shop model that puts each type to work so you can see where each one shines. + +In this tutorial, you: + +> [!div class="checklist"] +> +> - Return multiple values from a method with a tuple. +> - Model immutable data with a record class. +> - Represent small value types with a record struct. +> - Manage mutable state and behavior with a class. +> - Define shared capabilities with an interface. + +## Prerequisites + +- Install the [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) or later. + +## Use a tuple for a temporary grouping + +When a method needs to return two or three values and you don't want to declare a dedicated type, use a **tuple**. Named elements make the intent clear at the call site, and you can deconstruct the result into separate variables when that reads better. + +:::code language="csharp" source="./snippets/choosing-types/Program.cs" id="TupleDemo"::: + +The `GetDailySummary` method returns an `(int TotalOrders, decimal Revenue)` tuple. The caller accesses each element by name or deconstructs both into local variables. No class or struct definition is needed. + +**When to reach for a tuple:** + +- A method returns two or three related values. +- The grouping is local—callers don't pass the result across many layers. +- Named elements are enough to convey meaning without a full type. + +If you find yourself passing the same tuple shape across multiple methods, promote the tuple to a record or class. For more detail on tuple syntax and capabilities, see [Tuple types](../types/tuples.md). + +## Use a record for immutable data + +When two instances with the same data should be considered equal—and you rarely change the values after creation—use a **record class**. Records give you value-based equality, a readable `ToString()`, and the `with` expression for creating modified copies. + +Start by declaring a positional record: + +:::code language="csharp" source="./snippets/choosing-types/Program.cs" id="MenuItem"::: + +The compiler generates a constructor, deconstructor, `Equals`, `GetHashCode`, and `ToString` from that single line. Use the record in your coffee shop: + +:::code language="csharp" source="./snippets/choosing-types/Program.cs" id="RecordClassDemo"::: + +Two `MenuItem` instances built from the same values are equal even though they're separate objects. The `with` expression creates a seasonal variant without mutating the original. + +**When to reach for a record class:** + +- Identity is defined by data, not by object reference. +- Instances are mostly immutable after creation. +- You want readable `ToString()` output and structural equality out of the box. + +For a deeper walkthrough, see [Records](../types/records.md) and the [records tutorial](records.md). + +## Use a record struct for small value types + +A **record struct** combines value semantics with the convenience of records. Because structs are copied on assignment, each variable holds its own data—changes to one copy never affect another. Use a record struct when the data is small and copying is cheap. + +Declare a record struct: + +:::code language="csharp" source="./snippets/choosing-types/Program.cs" id="Measurement"::: + +Then use the record struct: + +:::code language="csharp" source="./snippets/choosing-types/Program.cs" id="RecordStructDemo"::: + +Assigning `temp` to `copy` creates an independent value. The `with` expression produces a new value without touching the original—the same pattern as a record class, but with copy-on-assign behavior instead of copy-by-reference. + +**When to reach for a record struct:** + +- The data is small (a few primitive fields). +- You want value equality and `with` support. +- Copying is cheaper than heap allocation—common for measurements, coordinates, and similar lightweight data. + +For more context, see [Records](../types/records.md) and [Structure types](../types/structs.md). + +## Use a class when you need mutable state and behavior + +Classes are reference types with identity. Two variables can point to the same object, and mutations through one variable are visible through the other. Reach for a class when an entity carries mutable state, exposes behavior, or needs to be tracked by reference. + +:::code language="csharp" source="./snippets/choosing-types/Program.cs" id="Order"::: + +The `Order` class tracks items, computes a running total, and exposes a settable `Status`. This kind of mutation-heavy, behavior-rich type is a natural fit for a class. + +:::code language="csharp" source="./snippets/choosing-types/Program.cs" id="ClassDemo"::: + +**When to reach for a class:** + +- The object has mutable state that changes over its lifetime. +- Behavior (methods) is central to the type's purpose. +- Identity matters—two orders with the same items are still distinct orders. + +For more detail, see [Classes, structs, and records](../types/classes.md). + +## Use an interface to define shared capabilities + +An **interface** declares a contract—a set of members that any implementing type must provide. Use an interface when unrelated types need to share a capability, or when you want to swap implementations at run time. + +:::code language="csharp" source="./snippets/choosing-types/Program.cs" id="Interfaces"::: + +The `Checkout` method accepts any `IDiscountPolicy`, so you can introduce new policies without changing the checkout logic: + +:::code language="csharp" source="./snippets/choosing-types/Program.cs" id="InterfaceDemo"::: + +**When to reach for an interface:** + +- Multiple unrelated types share a behavior (for example, discounting, serializing, or logging). +- You want to swap implementations at run time or in tests. +- You need polymorphic dispatch without a common base class. + +For more detail, see [Interfaces](../types/interfaces.md). + +## Quick decision guide + +Use this table as a starting point when you aren't sure which type to pick: + +| Question | Best fit | +|---|---| +| Returning a few values from one method? | Tuple | +| Immutable data where equality is by values? | Record class | +| Small, copyable value data with equality? | Record struct | +| Mutable state, behavior, or reference identity? | Class | +| Shared capability across unrelated types? | Interface | + +If none of these fit neatly, consider combining types. For example, a class can implement an interface, and a record can be a struct. For the full comparison, see [Choose which kind of type](../types/index.md#choose-which-kind-of-type). + +## See also + +- [Tuple types](../types/tuples.md) +- [Records](../types/records.md) +- [Structure types](../types/structs.md) +- [Classes, structs, and records](../types/classes.md) +- [Interfaces](../types/interfaces.md) +- [Choose which kind of type](../types/index.md#choose-which-kind-of-type) From 61aca0a32fdf586c1dbab3d15b98292a11d11379 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Wed, 15 Apr 2026 15:26:27 -0400 Subject: [PATCH 3/6] Update TOC --- docs/csharp/toc.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/csharp/toc.yml b/docs/csharp/toc.yml index db8dec26d82c5..6dfa5e1aaf0c3 100644 --- a/docs/csharp/toc.yml +++ b/docs/csharp/toc.yml @@ -143,6 +143,8 @@ items: - name: Converting types displayName: cast, is, as href: fundamentals/tutorials/safely-cast-using-pattern-matching-is-and-as-operators.md + - name: Choosing between tuples, records, structs, and classes + href: fundamentals/tutorials/choosing-types.md - name: Build data-driven algorithms with pattern matching href: fundamentals/tutorials/pattern-matching.md # separating data and algorithms From 6fcd4784cb40addde3ade8f6b8ee341a013f1b51 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Wed, 15 Apr 2026 15:48:03 -0400 Subject: [PATCH 4/6] Add an inheritance scenario Add an example of an inheritance scenario in this tutorial. --- .../fundamentals/tutorials/choosing-types.md | 23 +++++++++ .../snippets/choosing-types/Program.cs | 49 ++++++++++++++++++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/docs/csharp/fundamentals/tutorials/choosing-types.md b/docs/csharp/fundamentals/tutorials/choosing-types.md index 588cde468a049..855baafe0b13a 100644 --- a/docs/csharp/fundamentals/tutorials/choosing-types.md +++ b/docs/csharp/fundamentals/tutorials/choosing-types.md @@ -105,6 +105,28 @@ The `Order` class tracks items, computes a running total, and exposes a settable For more detail, see [Classes, structs, and records](../types/classes.md). +## Extend a class with inheritance + +Because `Order` is a class, you can derive from it to add state, behavior, or stricter rules. A `CateringOrder` adds a guest count, requires manager approval before the order can be marked ready, and overrides `ToString()` to include the extra details. + +:::code language="csharp" source="./snippets/choosing-types/Program.cs" id="CateringOrder"::: + +`CateringOrder` reuses `AddItem` and `Total` from the base class. The `Status` override tightens the contract—calling `Status = "Ready"` without prior approval throws an exception: + +:::code language="csharp" source="./snippets/choosing-types/Program.cs" id="InheritanceDemo"::: + +This pattern illustrates three inheritance concepts in one type: + +- **Added state**: `MinimumGuests` and `ApprovedBy` exist only on the derived class. +- **Added behavior**: `Approve` is new—base `Order` doesn't know about approvals. +- **Overridden behavior**: the `Status` setter enforces a business rule that the base class doesn't have. + +**When to derive from a class:** + +- The new type *is a* specialized version of the base type. +- You need to reuse existing state and behavior while adding or tightening rules. +- A shared base class is more natural than an interface because the types share implementation, not just a contract. + ## Use an interface to define shared capabilities An **interface** declares a contract—a set of members that any implementing type must provide. Use an interface when unrelated types need to share a capability, or when you want to swap implementations at run time. @@ -133,6 +155,7 @@ Use this table as a starting point when you aren't sure which type to pick: | Immutable data where equality is by values? | Record class | | Small, copyable value data with equality? | Record struct | | Mutable state, behavior, or reference identity? | Class | +| Specialized version of an existing class? | Derived class | | Shared capability across unrelated types? | Interface | If none of these fit neatly, consider combining types. For example, a class can implement an interface, and a record can be a struct. For the full comparison, see [Choose which kind of type](../types/index.md#choose-which-kind-of-type). diff --git a/docs/csharp/fundamentals/tutorials/snippets/choosing-types/Program.cs b/docs/csharp/fundamentals/tutorials/snippets/choosing-types/Program.cs index 4847d70c4631a..1b731d00ad480 100644 --- a/docs/csharp/fundamentals/tutorials/snippets/choosing-types/Program.cs +++ b/docs/csharp/fundamentals/tutorials/snippets/choosing-types/Program.cs @@ -46,6 +46,26 @@ Console.WriteLine(order); // +// +Console.WriteLine("\n=== Inheritance: CateringOrder ==="); +var catering = new CateringOrder(minimumGuests: 20); +catering.AddItem("Coffee (serves 20)", 45.00m); +catering.AddItem("Pastry platter", 60.00m); + +try +{ + catering.Status = "Ready"; +} +catch (InvalidOperationException ex) +{ + Console.WriteLine($"Blocked: {ex.Message}"); +} + +catering.Approve("Sam"); +catering.Status = "Ready"; +Console.WriteLine(catering); +// + // static decimal Checkout(decimal total, IDiscountPolicy policy) => policy.Apply(total); @@ -82,7 +102,7 @@ record struct Measurement(double Value, string Unit); // class Order { - public string Status { get; set; } = "Pending"; + public virtual string Status { get; set; } = "Pending"; private readonly List<(string Name, decimal Price)> _items = []; public void AddItem(string name, decimal price) => _items.Add((name, price)); @@ -94,6 +114,33 @@ public override string ToString() => } // +// +class CateringOrder : Order +{ + public int MinimumGuests { get; } + public string? ApprovedBy { get; private set; } + + public CateringOrder(int minimumGuests) => MinimumGuests = minimumGuests; + + public void Approve(string manager) => ApprovedBy = manager; + + public override string Status + { + get => base.Status; + set + { + if (value == "Ready" && ApprovedBy is null) + throw new InvalidOperationException( + "A catering order requires manager approval before it can be marked ready."); + base.Status = value; + } + } + + public override string ToString() => + $"Catering [{Status}] for {MinimumGuests}+ guests, approved by: {ApprovedBy ?? "(none)"} - Total: {Total:F2}"; +} +// + // interface IDiscountPolicy { From 8636da150ad124f4593d988f047f9dbacb89a093 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Wed, 15 Apr 2026 16:44:38 -0400 Subject: [PATCH 5/6] add grow up scenarios Discuss how you can change these decisions over time. --- .../fundamentals/tutorials/choosing-types.md | 36 ++++++++ .../snippets/choosing-types/Program.cs | 84 +++++++++++++++---- 2 files changed, 104 insertions(+), 16 deletions(-) diff --git a/docs/csharp/fundamentals/tutorials/choosing-types.md b/docs/csharp/fundamentals/tutorials/choosing-types.md index 855baafe0b13a..da7747452cf0c 100644 --- a/docs/csharp/fundamentals/tutorials/choosing-types.md +++ b/docs/csharp/fundamentals/tutorials/choosing-types.md @@ -145,6 +145,42 @@ The `Checkout` method accepts any `IDiscountPolicy`, so you can introduce new po For more detail, see [Interfaces](../types/interfaces.md). +## Evolve your type choices + +None of these decisions are permanent—especially before you release a library where breaking changes become costly. As requirements grow, promote a simple type to a richer one. Here are three common evolutions. + +### Tuple → record: the grouping keeps showing up + +The `GetDailySummary` tuple works fine inside one method, but once you start passing it to reports, dashboards, and tests, a named type pays for itself. Promote the tuple to a record and add computed properties: + +:::code language="csharp" source="./snippets/choosing-types/Program.cs" id="DailySummary"::: + +Callers that previously destructured the tuple now get `ToString()` for free, value equality, and a natural place for derived data like `AverageTicket`: + +:::code language="csharp" source="./snippets/choosing-types/Program.cs" id="EvolveTupleToRecord"::: + +### Struct → class: you need inheritance + +The `Measurement` record struct is great until you need a specialized variant—say, a calibrated reading that adjusts the value by an offset. Structs don't support inheritance, so you promote to a class hierarchy: + +:::code language="csharp" source="./snippets/choosing-types/Program.cs" id="SensorReading"::: + +`CalibratedReading` inherits from `SensorReading` and overrides `Display()` to include the offset. This pattern isn't possible with a struct or record struct: + +:::code language="csharp" source="./snippets/choosing-types/Program.cs" id="EvolveStructToClass"::: + +### Class → class + interface: you need polymorphism across types + +The `Order` class works well on its own, but once `CateringOrder` exists, other code—checkout, reporting, printing—needs to work with *any* order. Extract an interface with the members that callers actually depend on: + +:::code language="csharp" source="./snippets/choosing-types/Program.cs" id="IOrder"::: + +Both `Order` and `CateringOrder` already satisfy this contract. Now a single method handles either type: + +:::code language="csharp" source="./snippets/choosing-types/Program.cs" id="EvolveClassToInterface"::: + +Extracting the interface doesn't change `Order` or `CateringOrder`—it just makes their shared shape explicit, which also makes testing easier. + ## Quick decision guide Use this table as a starting point when you aren't sure which type to pick: diff --git a/docs/csharp/fundamentals/tutorials/snippets/choosing-types/Program.cs b/docs/csharp/fundamentals/tutorials/snippets/choosing-types/Program.cs index 1b731d00ad480..d959fd148f8cc 100644 --- a/docs/csharp/fundamentals/tutorials/snippets/choosing-types/Program.cs +++ b/docs/csharp/fundamentals/tutorials/snippets/choosing-types/Program.cs @@ -75,21 +75,40 @@ Console.WriteLine($"Loyalty ($1 off): {Checkout(subtotal, new LoyaltyDiscount()):F2}"); // -// -Console.WriteLine("\n=== Combined coffee shop scenario ==="); -var order2 = new Order(); -order2.AddItem("Espresso", 3.00m); -order2.Status = "Complete"; - -var drinkOfTheDay = new MenuItem("Cold Brew", 4.00m, "Vegan") with { Price = 3.50m }; -var brewTemp = new Measurement(38.0, "°F"); -var daySummary = GetDailySummary(120, 525.75m); - -Console.WriteLine($"Today's special: {drinkOfTheDay.Name} at {drinkOfTheDay.Price:F2}"); -Console.WriteLine($"Brew temp: {brewTemp.Value}{brewTemp.Unit}"); -Console.WriteLine($"Day total: {daySummary.TotalOrders} orders / {daySummary.Revenue:F2}"); -Console.WriteLine(order2); -// +// +Console.WriteLine("\n=== Evolve: tuple -> record ==="); +var daily = new DailySummary(120, 525.75m); +Console.WriteLine(daily); +Console.WriteLine($"Average ticket: {daily.AverageTicket:F2}"); +// + +// +Console.WriteLine("\n=== Evolve: struct -> class ==="); +var raw = new SensorReading(72.5, "°F"); +var calibrated = new CalibratedReading(72.5, "°F", offset: -0.3); + +Console.WriteLine($"Raw: {raw.Display()}"); +Console.WriteLine($"Calibrated: {calibrated.Display()}"); +// + +// +Console.WriteLine("\n=== Evolve: class -> class + interface ==="); +static void PrintOrderSummary(IOrder o) => + Console.WriteLine($" {o.Total:F2} [{o.Status}]"); + +var walkIn = new Order(); +walkIn.AddItem("Mocha", 5.00m); +walkIn.Status = "Ready"; + +var banquet = new CateringOrder(minimumGuests: 50); +banquet.AddItem("Coffee service", 90.00m); +banquet.Approve("Alex"); +banquet.Status = "Ready"; + +Console.WriteLine("All orders:"); +foreach (IOrder o in new IOrder[] { walkIn, banquet }) + PrintOrderSummary(o); +// // record class MenuItem(string Name, decimal Price, string NutritionalNote); @@ -99,8 +118,41 @@ record class MenuItem(string Name, decimal Price, string NutritionalNote); record struct Measurement(double Value, string Unit); // +// +record class DailySummary(int TotalOrders, decimal Revenue) +{ + public decimal AverageTicket => TotalOrders > 0 ? Revenue / TotalOrders : 0m; +} +// + +// +class SensorReading(double value, string unit) +{ + public double Value { get; } = value; + public string Unit { get; } = unit; + + public virtual string Display() => $"{Value}{Unit}"; +} + +class CalibratedReading(double value, string unit, double offset) + : SensorReading(value, unit) +{ + public double Offset { get; } = offset; + + public override string Display() => $"{Value + Offset}{Unit} (offset {Offset:+0.0;-0.0})"; +} +// + +// +interface IOrder +{ + string Status { get; set; } + decimal Total { get; } +} +// + // -class Order +class Order : IOrder { public virtual string Status { get; set; } = "Pending"; private readonly List<(string Name, decimal Price)> _items = []; From f797fb476633265ea38cb252e5288b37dca8737a Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 16 Apr 2026 15:28:53 -0400 Subject: [PATCH 6/6] First pass content edit. Restructures this article to provide a better context and motivations. --- .../fundamentals/tutorials/choosing-types.md | 89 ++++++------------- 1 file changed, 28 insertions(+), 61 deletions(-) diff --git a/docs/csharp/fundamentals/tutorials/choosing-types.md b/docs/csharp/fundamentals/tutorials/choosing-types.md index da7747452cf0c..28f3aa8802878 100644 --- a/docs/csharp/fundamentals/tutorials/choosing-types.md +++ b/docs/csharp/fundamentals/tutorials/choosing-types.md @@ -11,17 +11,20 @@ ai-usage: ai-assisted > [!TIP] > This article is part of the **Fundamentals** section, written for developers who know at least one programming language and are learning C#. If you're new to programming, start with [Get started](../../tour-of-csharp/index.yml). For a quick reference table, see [Choose which kind of type](../types/index.md#choose-which-kind-of-type). -C# gives you several ways to group data: tuples, record classes, record structs, classes, and interfaces. Picking the right one depends on whether you need naming, equality, mutability, or polymorphism. In this tutorial, you build a small coffee shop model that puts each type to work so you can see where each one shines. +One of your first design decisions in any C# application is choosing which kind of type to create. Should a menu item be a `class` or a `record`? Should a quick calculation return a `tuple` or a named type? Each choice shapes how your code handles equality, mutability, and polymorphism—and the wrong pick leads to boilerplate, bugs, or both. + +In this tutorial, you build a small coffee shop model—menu items, orders, sensor readings, and discount policies—that puts each type to work. Along the way, you learn to recognize the design pressures that point toward one type over another. In this tutorial, you: > [!div class="checklist"] > -> - Return multiple values from a method with a tuple. -> - Model immutable data with a record class. -> - Represent small value types with a record struct. +> - Recognize when a tuple is the right fit for returning multiple values. +> - Model immutable data with a record class and understand value-based equality. +> - Represent small, copyable data with a record struct. > - Manage mutable state and behavior with a class. -> - Define shared capabilities with an interface. +> - Extend a class through inheritance to add or tighten rules. +> - Define shared capabilities across unrelated types with an interface. ## Prerequisites @@ -29,85 +32,59 @@ In this tutorial, you: ## Use a tuple for a temporary grouping -When a method needs to return two or three values and you don't want to declare a dedicated type, use a **tuple**. Named elements make the intent clear at the call site, and you can deconstruct the result into separate variables when that reads better. +The coffee shop needs a method that returns both the total number of orders and the revenue for the day. You could define a class or struct for that, but two values from one method don't always justify a new type. :::code language="csharp" source="./snippets/choosing-types/Program.cs" id="TupleDemo"::: -The `GetDailySummary` method returns an `(int TotalOrders, decimal Revenue)` tuple. The caller accesses each element by name or deconstructs both into local variables. No class or struct definition is needed. - -**When to reach for a tuple:** +`GetDailySummary` returns an `(int TotalOrders, decimal Revenue)` **tuple**. The caller accesses each element by name or deconstructs both into local variables—no class or struct definition needed. -- A method returns two or three related values. -- The grouping is local—callers don't pass the result across many layers. -- Named elements are enough to convey meaning without a full type. - -If you find yourself passing the same tuple shape across multiple methods, promote the tuple to a record or class. For more detail on tuple syntax and capabilities, see [Tuple types](../types/tuples.md). +A tuple works here because the grouping is local: one method produces it, and one caller consumes it. Named elements make the intent clear without the ceremony of a full type. If you find yourself passing the same tuple shape across multiple methods, that's a signal to promote it to a record or class—you'll see that evolution [later in this tutorial](#tuple--record-the-grouping-keeps-showing-up). For more detail on tuple syntax and capabilities, see [Tuple types](../types/tuples.md). ## Use a record for immutable data -When two instances with the same data should be considered equal—and you rarely change the values after creation—use a **record class**. Records give you value-based equality, a readable `ToString()`, and the `with` expression for creating modified copies. +Every coffee shop needs a menu. A menu item has a name, a price, and a nutritional note—and those values don't change once the item is listed. Two systems that both reference a "Latte at $4.50" should agree they're talking about the same thing, even if they created separate objects. -Start by declaring a positional record: +Declare a positional record: :::code language="csharp" source="./snippets/choosing-types/Program.cs" id="MenuItem"::: -The compiler generates a constructor, deconstructor, `Equals`, `GetHashCode`, and `ToString` from that single line. Use the record in your coffee shop: +The compiler generates a constructor, deconstructor, `Equals`, `GetHashCode`, and `ToString` from that single line. Put the record to work: :::code language="csharp" source="./snippets/choosing-types/Program.cs" id="RecordClassDemo"::: -Two `MenuItem` instances built from the same values are equal even though they're separate objects. The `with` expression creates a seasonal variant without mutating the original. - -**When to reach for a record class:** +Two `MenuItem` instances with the same data are equal even though they're separate objects—that's value-based equality at work. The `with` expression creates a seasonal variant without mutating the original. -- Identity is defined by data, not by object reference. -- Instances are mostly immutable after creation. -- You want readable `ToString()` output and structural equality out of the box. - -For a deeper walkthrough, see [Records](../types/records.md) and the [records tutorial](records.md). +A **record class** is the right fit when identity comes from data, not from object reference, and instances rarely change after creation. You get readable `ToString()` output, structural equality, and `with` support out of the box. For a deeper walkthrough, see [Records](../types/records.md) and the [records tutorial](records.md). ## Use a record struct for small value types -A **record struct** combines value semantics with the convenience of records. Because structs are copied on assignment, each variable holds its own data—changes to one copy never affect another. Use a record struct when the data is small and copying is cheap. +The coffee machine has a built-in thermometer that reports temperature readings. Each reading is tiny—a number and a unit—and gets copied into logs, alerts, and dashboards. You don't want a change in one copy to ripple through the others. Declare a record struct: :::code language="csharp" source="./snippets/choosing-types/Program.cs" id="Measurement"::: -Then use the record struct: +Use the record struct: :::code language="csharp" source="./snippets/choosing-types/Program.cs" id="RecordStructDemo"::: Assigning `temp` to `copy` creates an independent value. The `with` expression produces a new value without touching the original—the same pattern as a record class, but with copy-on-assign behavior instead of copy-by-reference. -**When to reach for a record struct:** - -- The data is small (a few primitive fields). -- You want value equality and `with` support. -- Copying is cheaper than heap allocation—common for measurements, coordinates, and similar lightweight data. - -For more context, see [Records](../types/records.md) and [Structure types](../types/structs.md). +A **record struct** fits when the data is small (a few primitive fields) and copying is cheaper than heap allocation. You get value equality and `with` support just like a record class, with true value semantics underneath. Measurements, coordinates, and similar lightweight data are natural candidates. For more context, see [Records](../types/records.md) and [Structure types](../types/structs.md). ## Use a class when you need mutable state and behavior -Classes are reference types with identity. Two variables can point to the same object, and mutations through one variable are visible through the other. Reach for a class when an entity carries mutable state, exposes behavior, or needs to be tracked by reference. +When a customer walks up to the counter, the barista starts an order and adds items one at a time. The total grows, the status changes from "Pending" to "Ready," and two orders placed at the same time—even with identical items—are still distinct orders. :::code language="csharp" source="./snippets/choosing-types/Program.cs" id="Order"::: -The `Order` class tracks items, computes a running total, and exposes a settable `Status`. This kind of mutation-heavy, behavior-rich type is a natural fit for a class. - :::code language="csharp" source="./snippets/choosing-types/Program.cs" id="ClassDemo"::: -**When to reach for a class:** - -- The object has mutable state that changes over its lifetime. -- Behavior (methods) is central to the type's purpose. -- Identity matters—two orders with the same items are still distinct orders. - -For more detail, see [Classes, structs, and records](../types/classes.md). +The `Order` class tracks items, computes a running total, and exposes a settable `Status`. A **class** is the right tool here because the object carries mutable state that changes over its lifetime, behavior (methods) is central to the type's purpose, and identity matters—two orders with the same items are still distinct orders. For more detail, see [Classes, structs, and records](../types/classes.md). ## Extend a class with inheritance -Because `Order` is a class, you can derive from it to add state, behavior, or stricter rules. A `CateringOrder` adds a guest count, requires manager approval before the order can be marked ready, and overrides `ToString()` to include the extra details. +The coffee shop starts catering events. A catering order is still an order—it has items and a total—but it also tracks a guest count and requires manager approval before the kitchen marks it ready. Rather than duplicating `Order`'s logic, derive a specialized class. :::code language="csharp" source="./snippets/choosing-types/Program.cs" id="CateringOrder"::: @@ -115,21 +92,17 @@ Because `Order` is a class, you can derive from it to add state, behavior, or st :::code language="csharp" source="./snippets/choosing-types/Program.cs" id="InheritanceDemo"::: -This pattern illustrates three inheritance concepts in one type: +This single derived class illustrates three inheritance concepts: - **Added state**: `MinimumGuests` and `ApprovedBy` exist only on the derived class. - **Added behavior**: `Approve` is new—base `Order` doesn't know about approvals. - **Overridden behavior**: the `Status` setter enforces a business rule that the base class doesn't have. -**When to derive from a class:** - -- The new type *is a* specialized version of the base type. -- You need to reuse existing state and behavior while adding or tightening rules. -- A shared base class is more natural than an interface because the types share implementation, not just a contract. +Inheritance fits when the new type *is a* specialized version of the base type and you need to reuse existing state and behavior while adding or tightening rules. A shared base class is more natural than an interface when the types share implementation, not just a contract. ## Use an interface to define shared capabilities -An **interface** declares a contract—a set of members that any implementing type must provide. Use an interface when unrelated types need to share a capability, or when you want to swap implementations at run time. +The coffee shop runs different promotions—happy hour, loyalty rewards, seasonal specials. The checkout process needs to apply whichever discount is active today, without knowing the specifics of each policy. You need a way to say "anything that can apply a discount" without tying checkout to a single class. :::code language="csharp" source="./snippets/choosing-types/Program.cs" id="Interfaces"::: @@ -137,13 +110,7 @@ The `Checkout` method accepts any `IDiscountPolicy`, so you can introduce new po :::code language="csharp" source="./snippets/choosing-types/Program.cs" id="InterfaceDemo"::: -**When to reach for an interface:** - -- Multiple unrelated types share a behavior (for example, discounting, serializing, or logging). -- You want to swap implementations at run time or in tests. -- You need polymorphic dispatch without a common base class. - -For more detail, see [Interfaces](../types/interfaces.md). +An **interface** declares a contract—a set of members that any implementing type must provide. The interface works here because the discount types are unrelated (they don't share a base class), yet checkout needs to treat them uniformly. Interfaces also make testing easy: swap in a stub policy without touching production code. For more detail, see [Interfaces](../types/interfaces.md). ## Evolve your type choices @@ -161,7 +128,7 @@ Callers that previously destructured the tuple now get `ToString()` for free, va ### Struct → class: you need inheritance -The `Measurement` record struct is great until you need a specialized variant—say, a calibrated reading that adjusts the value by an offset. Structs don't support inheritance, so you promote to a class hierarchy: +The shop's maintenance team asks for calibrated readings—a sensor value adjusted by an offset. The `Measurement` record struct is great for raw data, but structs don't support inheritance, so you can't derive a calibrated variant. Promote to a class hierarchy: :::code language="csharp" source="./snippets/choosing-types/Program.cs" id="SensorReading"::: @@ -171,7 +138,7 @@ The `Measurement` record struct is great until you need a specialized variant— ### Class → class + interface: you need polymorphism across types -The `Order` class works well on its own, but once `CateringOrder` exists, other code—checkout, reporting, printing—needs to work with *any* order. Extract an interface with the members that callers actually depend on: +The `Order` class works well on its own, but once `CateringOrder` exists, checkout, reporting, and printing all need to handle *any* order without caring which concrete type it is. Extract an interface with the members that callers actually depend on: :::code language="csharp" source="./snippets/choosing-types/Program.cs" id="IOrder":::