Skip to content
Search lessons, topics, tests…
Esc

    ↑ ↓ moveEnter openEsc close

    Module 2 · 2. Type System, Values, Nullability, and Pattern Matching · Lesson 6 of 24

    Enums, Tuples, Records, and Property Patterns

    Learning outcomes

    Choose a data shape by the behavior you need, trace a property-pattern decision, and keep input validation separate from a switch that merely handles every case. You will build a small order-state command-line program and test an unnamed numeric enum value.

    The four C# blocks below are four separate console programs for .NET 10. For each one, create a console project, replace Program.cs with that block, and run it from the project directory. Do not concatenate the blocks. Output blocks are results to compare, not C# source.

    A complete switch is not an input validator

    An enum supplies names for integral constants; values need not have a declared name. This lesson uses non-flags enums. Check the allowed values at a boundary instead of assuming a cast validated them. Keep persisted numeric codes deliberate rather than depending on declaration order.

    The original incident-routing policy remains below. Critical incidents page the commander only when CustomerImpact is true. High incidents older than 30 minutes escalate; other High and Medium incidents go to the on-call engineer. Everything else uses the normal queue. These are this example's chosen rules, not general incident-management advice.

    Property patterns require a non-null input and matching member conditions. In the switch, the first matching arm wins. The final discard arm covers remaining inputs; that makes the decision total, but does not prove those inputs are meaningful.

    For a separate boundary exercise, ValidateAndRoute requires a non-null incident, a named Severity and nonnegative MinutesOpen. Only after those checks does it call the unchanged Route function. That boundary contract is explicit; the positional Incident declaration does not enforce it by itself.

    C#
    using System;
    
    static string Route(Incident incident) => incident switch
    {
        { Severity: Severity.Critical, CustomerImpact: true } => "Page incident commander",
        { Severity: Severity.High, MinutesOpen: > 30 } => "Escalate to service owner",
        { Severity: Severity.Medium or Severity.High } => "Assign on-call engineer",
        _ => "Track in normal queue"
    };
    
    static string ValidateAndRoute(Incident? incident)
    {
        if (incident is null)
            return "Reject: incident is missing";
        if (!Enum.IsDefined(incident.Severity))
            return "Reject: severity is not a named value";
        if (incident.MinutesOpen < 0)
            return "Reject: minutes must be nonnegative";
        return Route(incident);
    }
    
    Console.WriteLine($"critical-impact: {Route(new Incident(Severity.Critical, true, 4))}");
    Console.WriteLine($"critical-no-impact: {Route(new Incident(Severity.Critical, false, 4))}");
    Console.WriteLine($"high-30: {Route(new Incident(Severity.High, false, 30))}");
    Console.WriteLine($"high-31: {Route(new Incident(Severity.High, false, 31))}");
    Console.WriteLine($"medium-31: {Route(new Incident(Severity.Medium, false, 31))}");
    Console.WriteLine($"low-31: {Route(new Incident(Severity.Low, false, 31))}");
    Console.WriteLine($"raw-unnamed: {Route(new Incident((Severity)99, false, 4))}");
    Console.WriteLine($"checked-unnamed: {ValidateAndRoute(new Incident((Severity)99, false, 4))}");
    Console.WriteLine($"checked-null: {ValidateAndRoute(null)}");
    Console.WriteLine($"checked-negative: {ValidateAndRoute(new Incident(Severity.High, false, -1))}");
    Console.WriteLine($"checked-valid: {ValidateAndRoute(new Incident(Severity.Critical, true, 4))}");
    
    public enum Severity { Low, Medium, High, Critical }
    public sealed record Incident(Severity Severity, bool CustomerImpact, int MinutesOpen);

    Expected output:

    Code
    critical-impact: Page incident commander
    critical-no-impact: Track in normal queue
    high-30: Assign on-call engineer
    high-31: Escalate to service owner
    medium-31: Assign on-call engineer
    low-31: Track in normal queue
    raw-unnamed: Track in normal queue
    checked-unnamed: Reject: severity is not a named value
    checked-null: Reject: incident is missing
    checked-negative: Reject: minutes must be nonnegative
    checked-valid: Page incident commander

    Follow high-31 through the arms: it misses Critical, matches High with MinutesOpen greater than 30, and stops before the broader High-or-Medium arm. high-30 misses the greater-than condition and reaches the third arm. critical-no-impact is intentionally handled by the fallback.

    Compare raw-unnamed with checked-unnamed. The same cast-created value reaches the normal queue through Route, but is rejected before routing through ValidateAndRoute. The null and negative-minute cases exercise the other boundary checks. A catch-all is not a substitute for these decisions.

    A named tuple for a small local result

    A tuple can return several values without a separate declaration. Element names improve the caller's code, but do not create a distinct nominal type; assignment follows element positions and compatible types. Prefer a named model when callers need a durable contract with its own behavior.

    CountStates accepts a non-null array of known Boolean states and returns two counts. The small demonstration supplies that array directly; input collection and validation are outside this helper.

    C#
    using System;
    
    static (int Open, int Closed) CountStates(bool[] closedStates)
    {
        int open = 0;
        int closed = 0;
        foreach (bool isClosed in closedStates)
        {
            if (isClosed)
                closed++;
            else
                open++;
        }
        return (open, closed);
    }
    
    var result = CountStates(new[] { false, true, false });
    Console.WriteLine($"named: open={result.Open}; closed={result.Closed}");
    var (openCount, closedCount) = result;
    Console.WriteLine($"deconstructed: open={openCount}; closed={closedCount}");
    
    (int Pending, int Done) renamed = result;
    Console.WriteLine($"renamed: pending={renamed.Pending}; done={renamed.Done}");
    renamed.Pending = 99;
    Console.WriteLine($"after copy edit: original={result.Open}; copy={renamed.Pending}");
    Console.WriteLine($"empty: {CountStates(Array.Empty<bool>())}");

    Expected output:

    Code
    named: open=2; closed=1
    deconstructed: open=2; closed=1
    renamed: pending=2; done=1
    after copy edit: original=2; copy=99
    empty: (0, 0)

    The first two lines read the same pair in two ways: named members and deconstruction. The Pending/Done assignment changes the labels used by this variable, not the meaning or order of the two numbers. Editing its copied integer field leaves result.Open at 2. The empty array produces two zero counts.

    Records: compare data, then inspect what a copy shares

    Records add synthesized value equality. A record class is a reference type; a record struct is a value type. Positional record-class properties are init-only, but referenced objects can still change. Neither a record name nor concise syntax supplies an application's validation rules.

    In this example, Ticket models a snapshot. TicketEntity is an ordinary class with no equality override. ReviewBatch intentionally contains a mutable array so the copy behavior is visible.

    C#
    using System;
    
    var first = new Ticket(7, "normal");
    var second = new Ticket(7, "normal");
    Console.WriteLine($"record equality: {first == second}");
    Console.WriteLine($"same record object: {ReferenceEquals(first, second)}");
    
    var urgent = first with { Queue = "urgent" };
    Console.WriteLine($"with: original={first.Queue}; copy={urgent.Queue}");
    Console.WriteLine($"same object after with: {ReferenceEquals(first, urgent)}");
    
    var entityA = new TicketEntity(7, "normal");
    var entityB = new TicketEntity(7, "normal");
    Console.WriteLine($"plain class equality: {entityA == entityB}");
    
    var batch = new ReviewBatch("original", new[] { "new" });
    var batchCopy = batch with { Label = "copy" };
    Console.WriteLine($"shared array: {ReferenceEquals(batch.Tags, batchCopy.Tags)}");
    batchCopy.Tags[0] = "reviewed";
    Console.WriteLine($"after nested edit: original={batch.Tags[0]}; copy={batchCopy.Tags[0]}");
    
    var separate = new ReviewBatch("original", new[] { "reviewed" });
    Console.WriteLine($"separate arrays compare equal: {batch == separate}");
    
    public sealed record Ticket(int Number, string Queue);
    public sealed record ReviewBatch(string Label, string[] Tags);
    public sealed class TicketEntity(int number, string queue)
    {
        public int Number { get; } = number;
        public string Queue { get; } = queue;
    }

    Expected output:

    Code
    record equality: True
    same record object: False
    with: original=normal; copy=urgent
    same object after with: False
    plain class equality: False
    shared array: True
    after nested edit: original=reviewed; copy=reviewed
    separate arrays compare equal: False

    first and second have equal Ticket data but are different objects. The with expression changes Queue only in urgent. The two TicketEntity objects do not compare equal in this program, despite the matching property values.

    The batch experiment has a different result: with makes a new outer record while both records still refer to the same Tags array. Editing that array through batchCopy is visible through batch. separate has matching text inside a different array, and the final comparison is false. This example is not doing a deep collection comparison.

    These examples make the choice concrete: Ticket compares snapshot data, TicketEntity keeps object identity, and ReviewBatch shows why nested mutable state needs an ownership decision. None of the constructors here checks domain rules. Design that boundary explicitly for your model.

    Practice: order states and permitted next actions

    Build OrderActions with one numeric state-code argument. This exercise defines the following policy: Draft (0) allows edit, submit and cancel; Submitted (10) allows pay and cancel; Paid (20) allows ship and refund; Shipped (30) allows track; Cancelled (40) has no next action.

    Validate argument count, then integer conversion, then membership in the declared state codes. The input spelling permits a leading sign but no surrounding spaces. A successfully converted integer such as 99 is still outside this exercise's allowed states. Print a specific rejection and use exit codes 2, 3 and 4 for those three failure stages; return 0 on success.

    This is a decision exercise, not a production order system. It reports allowed action names without changing an order or implementing payment, shipping, concurrency or authorization.

    C#
    using System;
    using System.Globalization;
    
    if (args.Length != 1)
    {
        Console.WriteLine("Usage: OrderActions <state-code>");
        return 2;
    }
    
    if (!int.TryParse(args[0], NumberStyles.AllowLeadingSign,
            CultureInfo.InvariantCulture, out int code))
    {
        Console.WriteLine("State code must be an integer without spaces.");
        return 3;
    }
    
    OrderState state = (OrderState)code;
    if (!Enum.IsDefined(state))
    {
        Console.WriteLine("Unknown state code. Use 0, 10, 20, 30 or 40.");
        return 4;
    }
    
    string actions = state switch
    {
        OrderState.Draft => "edit, submit, cancel",
        OrderState.Submitted => "pay, cancel",
        OrderState.Paid => "ship, refund",
        OrderState.Shipped => "track",
        OrderState.Cancelled => "none",
        _ => "no policy defined"
    };
    
    Console.WriteLine($"{state}: {actions}");
    return 0;
    
    public enum OrderState
    {
        Draft = 0,
        Submitted = 10,
        Paid = 20,
        Shipped = 30,
        Cancelled = 40
    }

    Trace and test the solution

    1. dotnet run -- 20 passes all three checks and prints Paid: ship, refund; exit 0.
    1. dotnet run -- 40 prints Cancelled: none; exit 0. A valid terminal state is different from unknown input.
    1. dotnet run -- 99 converts to an integer, then prints Unknown state code. Use 0, 10, 20, 30 or 40.; exit 4. It does not reach the switch.
    1. dotnet run -- Draft fails the integer-input contract and exits 3. The program did not promise to parse state names.
    1. No arguments, or two arguments, prints the usage message and exits 2.

    Try codes 0, 10 and 30, then -1, +10, 10.5, an empty argument and 2147483648 (beyond int range). Predict which check runs first.

    Expected answer key:

    • 0: Draft: edit, submit, cancel; exit 0.
    • 10 and +10: Submitted: pay, cancel; exit 0. The leading plus sign is allowed.
    • 30: Shipped: track; exit 0.
    • -1: Unknown state code. Use 0, 10, 20, 30 or 40.; exit 4. Integer conversion succeeds, but named-state validation rejects it.
    • 10.5, one empty argument ("") and 2147483648: State code must be an integer without spaces.; exit 3. Each fails integer conversion, before named-state validation. One empty argument is different from supplying no arguments.

    The numeric codes are assigned explicitly; moving a declaration line does not change these assignments. Changing a code still changes this exercise's input contract.

    The switch retains a defensive fallback. If you later add a named state without deciding its actions, IsDefined alone will not supply the missing policy. Add an explicit arm and an expected-result test whenever you extend the exercise.

    Failure modes and interview answer

    • Confusing an enum conversion with membership in an application's allowed states.
    • Treating a catch-all as proof that input was validated, or moving a broad match before a more specific routing rule.
    • Assuming tuple labels are a new model type, or using an unwieldy result whose positional meaning callers must memorize.
    • Assuming a record automatically rejects bad data or that with recursively copies every nested object.

    Question: Which shape would you use for the count result, a ticket snapshot and an identity-bearing ticket object?

    Answer: The two local counts are easy to name and deconstruct as a tuple. Ticket makes the snapshot's data comparison explicit in the demonstration. TicketEntity represents a separate identity choice. I would check ownership, equality, copying and validation needs rather than declaring one type the universal winner.

    References

    Microsoft C# documentation: enumeration types, tuple types, records, and patterns.

    Analogy

    Everyday picture

    Imagine two job cards showing the same ticket number and queue. Their details agree, but they are still separate cards. You can make another card with a different queue without altering the first. If both cards point to one shared checklist, however, a mark on that checklist is visible from either card.

    Mapping. The lesson's two Ticket records compare equal by their data while ReferenceEquals reports different objects. with { Queue = "urgent" } creates a new record with a changed queue. The shared checklist represents ReviewBatch.Tags: copying the outer record with with leaves both records referring to the same mutable array.

    Where it stops. Record equality is not a promise of deep comparison, and with is not a deep copy. In this lesson, matching tag text in two different arrays does not make the batches equal. Declaring a record does not automatically make nested objects immutable or validate the application's rules.

    Cheat sheet (PDF)

    csharp-data-shapes-patterns-companion.pdf7 pages · 73 KB
    Every page, in this page.

    Practice

    Sign in to mark lessons done and keep your place in the course.Sign in