Skip to main content

Enums, Structs and Records: Statuses and Values – C# | Lesson 43

Published / updated

Introduction​

FeeInvoice.Status has been a string since Lesson 38. A string accepts anything: "Paid", "paid", "Partially Paid" with a space. Every typo compiles, and the bug appears only when a real invoice is processed.

This lesson introduces three kinds of types for small, well-defined data. An enum is a fixed list of named values.

A struct is a small value type. A record is a type that compares by its values instead of by reference.

By the end of this lesson, you will be able to:

  • Replace status strings with enums that match the SchoolDb CHECK constraints.
  • Convert enums to and from the text stored in the database.
  • Explain the difference between a struct and a class.
  • Create records, compare them by value, and copy them with with.
  • Choose between class, struct, record, and record struct.

Why this matters​

SchoolDb protects its statuses with CHECK constraints, such as CK_FeeInvoices_Status. Until now, C# had no such protection: a typo in a status string passed every build and failed only at run time, or worse, silently matched nothing.

With enums, the compiler knows every valid status. Mistyped values do not compile, and your editor offers the list of valid values as you type. Records appear everywhere in modern C#: DTOs in Lesson 45, API requests and responses in Module 11.

The concept​

Example: an enum​

Example
public enum InvoiceStatus
{
Unpaid,
PartiallyPaid,
Paid,
Cancelled
}

An enum is a named list of fixed values. A variable of type InvoiceStatus can only hold one of them: InvoiceStatus.Paid, never "paid" or "Done".

Think of the attendance register. Each day has the same few boxes: Present, Absent, Late, Leave.

A teacher ticks one; nobody can write in a fifth option. An enum is that printed list of boxes.

Example: enums are numbers underneath​

Each enum member has a whole-number value. By default they count from 0: Unpaid is 0, PartiallyPaid is 1, and so on. You can also choose the values yourself.

Gender does that, to store the same CHAR(1) codes as SchoolDb:

Example
public enum Gender
{
Male = 'M',
Female = 'F',
Other = 'O'
}

A char is a number to C#, so 'M' is a valid value. (char)Gender.Female gives 'F' for the database, and (Gender)'M' turns the stored code back into Gender.Male.

Example: struct versus class​

ClassStruct
KindReference typeValue type
Assignment copiesThe reference: both variables share one objectThe whole value: two independent copies
Can be nullYesNo (unless Struct?)
InheritanceYesNo
Best forEntities with identity and behaviour: Student, FeeInvoiceSmall values: a date range, a point, a money amount

int, decimal, and DateOnly are all structs. That is why they behaved as copies in Lesson 34: changing a copy never changed the original.

Example: records​

Example
record MarkEntry(int EnrollmentId, int ExamSubjectId, decimal? MarksObtained);

This one line creates a full class with three properties, a constructor, and three extras:

A record gives youMeaning
Value equalityTwo records with the same values are ==
with expressionsA copy with some values changed
A readable ToString()Prints every property, such as MarkEntry { EnrollmentId = 13, ... }

The properties are init-only (Lesson 38), so a record is fixed once created. To "change" one, you make a copy with with. A record is still a class underneath: a reference type, unless you write record struct.

Apply it to the SMS​

Step 1: the seven SMS enums​

SchoolApp.Core/Enums gets one enum per status column in SchoolDb, as listed in the SMS reference: Gender, StudentStatus, AttendanceStatus, ExamType, FeeType, InvoiceStatus, and PaymentMode. The member names are copied exactly from the CHECK constraints:

src/SchoolApp.Core/Enums/InvoiceStatus.cs
namespace SchoolApp.Core.Enums;

// Lesson 43: names match CK_FeeInvoices_Status.
public enum InvoiceStatus
{
Unpaid,
PartiallyPaid,
Paid,
Cancelled
}
Show the other six enums
src/SchoolApp.Core/Enums/Gender.cs
namespace SchoolApp.Core.Enums;

// Lesson 43: values are the CHAR(1) codes stored in SchoolDb ('M', 'F', 'O').
public enum Gender
{
Male = 'M',
Female = 'F',
Other = 'O'
}
src/SchoolApp.Core/Enums/StudentStatus.cs
namespace SchoolApp.Core.Enums;

// Lesson 43: names match CK_Students_Status exactly, so ToString() gives the stored value.
public enum StudentStatus
{
Active,
Transferred,
Graduated,
Withdrawn
}
src/SchoolApp.Core/Enums/AttendanceStatus.cs
namespace SchoolApp.Core.Enums;

// Lesson 43: names match CK_AttendanceRecords_Status.
public enum AttendanceStatus
{
Present,
Absent,
Late,
Leave
}
src/SchoolApp.Core/Enums/ExamType.cs
namespace SchoolApp.Core.Enums;

// Lesson 43: names match CK_Exams_ExamType.
public enum ExamType
{
UnitTest,
Term,
Annual
}
src/SchoolApp.Core/Enums/FeeType.cs
namespace SchoolApp.Core.Enums;

// Lesson 43: names match CK_FeeStructures_FeeType.
public enum FeeType
{
Tuition,
Transport,
Library,
Exam
}
src/SchoolApp.Core/Enums/PaymentMode.cs
namespace SchoolApp.Core.Enums;

// Lesson 43: names match CK_FeePayments_PaymentMode.
public enum PaymentMode
{
Cash,
Card,
Online,
BankTransfer,
Cheque
}

FeeInvoice.Status becomes InvoiceStatus, and Student gets Gender and Status:

Show FeeInvoice.cs and Student.cs
src/SchoolApp.Core/Entities/FeeInvoice.cs
using SchoolApp.Core.Enums;
using SchoolApp.Core.Exceptions;

namespace SchoolApp.Core.Entities;

// Lesson 38: an invoice that protects its own rules (encapsulation).
// Lesson 43: Status is an InvoiceStatus enum instead of a string.
public class FeeInvoice
{
private decimal _amountPaid; // a private field: only code inside this class can change it

public int Id { get; set; }
public string InvoiceNumber { get; init; } // init: can be set only while the object is created
public decimal TotalAmount { get; } // no setter: fixed by the constructor
public decimal DiscountAmount { get; }
public InvoiceStatus Status { get; private set; } = InvoiceStatus.Unpaid; // only this class can write

public FeeInvoice(string invoiceNumber, decimal totalAmount, decimal discountAmount = 0m)
{
if (totalAmount < 0)
{
throw new ArgumentOutOfRangeException(nameof(totalAmount), "The total cannot be negative.");
}
if (discountAmount < 0 || discountAmount > totalAmount)
{
throw new ArgumentOutOfRangeException(nameof(discountAmount), "The discount must be between 0 and the total.");
}

InvoiceNumber = invoiceNumber;
TotalAmount = totalAmount;
DiscountAmount = discountAmount;
}

public decimal AmountPaid => _amountPaid;
public decimal Balance => TotalAmount - DiscountAmount - _amountPaid;

// The only way to pay. The same rules and error numbers as usp_RecordFeePayment (Lesson 22).
public void RecordPayment(decimal amount)
{
if (Status == InvoiceStatus.Cancelled)
{
throw new SchoolRuleException(50011, "Invoice is cancelled.");
}
if (amount <= 0)
{
throw new SchoolRuleException(50012, "Amount must be more than zero.");
}
if (amount > Balance)
{
throw new SchoolRuleException(50013, "Amount is more than the balance due.");
}

_amountPaid += amount;
Status = Balance == 0 ? InvoiceStatus.Paid : InvoiceStatus.PartiallyPaid;
}

// An invoice with payments on it cannot be cancelled; the payments must be refunded first.
public void Cancel()
{
if (_amountPaid > 0)
{
throw new InvalidOperationException("An invoice with payments cannot be cancelled.");
}
Status = InvoiceStatus.Cancelled;
}
}
src/SchoolApp.Core/Entities/Student.cs
using SchoolApp.Core.Enums;

namespace SchoolApp.Core.Entities;

// Lesson 37: a class describes what every student has (properties) and can do (methods).
// Lesson 38: names are checked and trimmed; the admission number can never change.
// Lesson 39: names, Id, and FullName now come from the Person base class.
// Lesson 42: sealed (no class may inherit from Student) and partial: this file holds the data,
// Student.Behaviour.cs holds the methods. Both parts make one class.
// Lesson 43: Gender and Status are enums.
public sealed partial class Student : Person
{
public string AdmissionNumber { get; } // set once, in the constructor
public DateOnly DateOfBirth { get; set; }
public Gender Gender { get; set; }
public StudentStatus Status { get; set; } = StudentStatus.Active; // the same default as DF_Students_Status

// base(...) runs Person's constructor first, which checks the names.
public Student(string admissionNumber, string firstName, string lastName, DateOnly dateOfBirth)
: base(firstName, lastName)
{
AdmissionNumber = RequireText(admissionNumber, nameof(AdmissionNumber));
DateOfBirth = dateOfBirth;
}
}

Step 2: a record struct for date ranges​

Academic years and exams both have a start and an end date. DateRange is a small value, so it is a struct; it should compare by value, so it is a record:

src/SchoolApp.Core/Common/DateRange.cs
namespace SchoolApp.Core.Common;

// Lesson 43: a small value: a start date and an end date, such as an academic year or an exam.
// struct: a value type, copied on assignment. record: compares by value and prints its values.
// readonly: once created, it can never change.
public readonly record struct DateRange(DateOnly Start, DateOnly End)
{
public bool Contains(DateOnly date) => date >= Start && date <= End;

public int Days => End.DayNumber - Start.DayNumber + 1;

// The default record ToString() prints dates in the computer's regional format; ISO is the same everywhere.
public override string ToString() => $"{Start:yyyy-MM-dd} to {End:yyyy-MM-dd}";
}

Step 3: the program​

Show src/SchoolApp.Console/Program.cs
src/SchoolApp.Console/Program.cs
// Lesson 43: enums, structs, and records.
using SchoolApp.Core.Common;
using SchoolApp.Core.Entities;
using SchoolApp.Core.Enums;

// 1. An enum replaces magic strings: the compiler now checks every status.
FeeInvoice invoice = new FeeInvoice("INV-2026-000002", 45500m);
invoice.RecordPayment(20000m);
Console.WriteLine($"Status: {invoice.Status}; is partially paid: {invoice.Status == InvoiceStatus.PartiallyPaid}");

string message = invoice.Status switch
{
InvoiceStatus.Unpaid => "Send a reminder",
InvoiceStatus.PartiallyPaid => "Send the balance due",
InvoiceStatus.Paid => "Send a thank-you note",
InvoiceStatus.Cancelled => "Nothing to send",
_ => "Unknown status"
};
Console.WriteLine($"Action: {message}");

// 2. Enums and the database: names match the CHECK constraints, so text converts both ways.
string storedValue = InvoiceStatus.PartiallyPaid.ToString();
InvoiceStatus fromDatabase = Enum.Parse<InvoiceStatus>("Paid");
bool isKnown = Enum.TryParse("Done", out InvoiceStatus _);
Console.WriteLine($"To SQL: '{storedValue}'; from SQL: {fromDatabase}; 'Done' is valid: {isKnown}");
Console.WriteLine($"All statuses: {string.Join(", ", Enum.GetNames<InvoiceStatus>())}");

// 3. Gender stores the CHAR(1) code as the enum's value.
Student diya = new Student("ADM-2017-0002", "Diya", "Patel", new DateOnly(2011, 8, 23)) { Gender = Gender.Female };
Console.WriteLine($"{diya.FullName}: {diya.Gender}, stored as '{(char)diya.Gender}', status {diya.Status}");
Console.WriteLine($"Read 'M' from SQL: {(Gender)'M'}");

Student unset = new Student("ADM-2017-0003", "Kabir", "Singh", new DateOnly(2011, 1, 30));
Console.WriteLine($"Gender never set: {unset.Gender}; defined: {Enum.IsDefined(unset.Gender)}");

// 4. A struct is copied by value; a record compares by value.
DateRange year2026 = new DateRange(new DateOnly(2026, 6, 1), new DateOnly(2027, 3, 31));
DateRange copy = year2026;
Console.WriteLine($"{year2026}");
Console.WriteLine($"{year2026.Days} days; contains 2026-07-01: {year2026.Contains(new DateOnly(2026, 7, 1))}; same as copy: {copy == year2026}");

// 5. A record class: data that compares by value, and a copy with one change.
MarkEntry entered = new MarkEntry(13, 8, 92m);
MarkEntry again = new MarkEntry(13, 8, 92m);
MarkEntry corrected = entered with { MarksObtained = 90m };
Console.WriteLine($"Same values are equal: {entered == again}");
Console.WriteLine($"Corrected copy: {corrected}");

// A record: one teacher's entry for one paper. Lesson 45 turns entries like this into DTOs.
record MarkEntry(int EnrollmentId, int ExamSubjectId, decimal? MarksObtained);
Output
Status: PartiallyPaid; is partially paid: True
Action: Send the balance due
To SQL: 'PartiallyPaid'; from SQL: Paid; 'Done' is valid: False
All statuses: Unpaid, PartiallyPaid, Paid, Cancelled
Diya Patel: Female, stored as 'F', status Active
Read 'M' from SQL: Male
Gender never set: 0; defined: False
2026-06-01 to 2027-03-31
304 days; contains 2026-07-01: True; same as copy: True
Same values are equal: True
Corrected copy: MarkEntry { EnrollmentId = 13, ExamSubjectId = 8, MarksObtained = 90 }

Example: enums and the database​

SchoolDb stores statuses as text, such as 'PartiallyPaid'. Because the enum names match the CHECK constraints letter for letter, conversion is simple in both directions:

DirectionCodeResult
C# to SQLInvoiceStatus.PartiallyPaid.ToString()"PartiallyPaid"
SQL to C#Enum.Parse<InvoiceStatus>("Paid")InvoiceStatus.Paid
Unknown textEnum.TryParse("Done", out InvoiceStatus _)false

Keep the names in step. Renaming an enum member without changing the constraint, or the other way round, breaks the conversion. In Module 9, data access tools use exactly these conversions.

Example: a switch the compiler can check​

The switch on invoice.Status lists all four statuses. Comparing an enum to a string no longer compiles: invoice.Status == "Paid" fails with error CS0019: Operator '==' cannot be applied to operands of type 'InvoiceStatus' and 'string'.

The compiler has turned a whole family of run-time bugs into build errors. That is the main reason to use enums.

Editors help too. Type InvoiceStatus. and the list of valid statuses appears; nobody has to remember whether it was "PartiallyPaid" or "Partial".

Example: the default value trap​

Kabir's Gender was never set, and printed 0, with Enum.IsDefined false. Every enum field starts at 0, its default, even when no member has the value 0.

For InvoiceStatus, 0 is Unpaid, a sensible default for a new invoice. Gender has no 0 member, so an unset gender is invalid. Lesson 44 fixes this by making gender a required constructor argument.

When you design an enum, decide deliberately what 0 should mean.

Example: struct copies and record equality​

DateRange copy = year2026; copies the two dates, not a reference. copy == year2026 is True, because a record compares values. The academic year 2026-27 has 304 days and contains 1 July 2026.

The MarkEntry records behave the same way. Two entries with the same values are equal, the opposite of the two Student objects in Lesson 37. entered with { MarksObtained = 90m } makes a corrected copy and leaves entered untouched.

Example: why Student is not a record​

If records are so convenient, why are Student and FeeInvoice still classes? Because they have identity.

Two different students can both be called Aarav Sharma, born on the same day. They are still two people, with two Id values and two histories. A record would call them equal, which would be wrong for a school.

Kind of dataEqual whenType
An entity, such as a student or an invoiceIt is the same record: same IdClass
A value, such as a date range or a mark entryAll values are the sameRecord or record struct

Entities also change over time: a student's status moves from Active to Graduated, and an invoice collects payments. Records are designed to stay fixed, which suits values and messages, not living entities.

Example: choosing the right type​

You needChooseSMS example
A fixed list of optionsenumInvoiceStatus, PaymentMode
Something with identity that changes over timeclassStudent, FeeInvoice
Data passed between layers, compared by valuerecordMarkEntry, DTOs in Lesson 45
A tiny, immutable value used oftenreadonly record structDateRange

When unsure, start with a class or a record. Choose a struct only for small values that are created in large numbers, where avoiding extra objects matters.

Common mistakes​

  1. Enum names that differ from the database values. Parsing fails at run time. Fix: copy names exactly from the CHECK constraint.
  2. Forgetting what 0 means. An unset enum holds 0, defined or not. Fix: make 0 a sensible default, or require the value in the constructor.
  3. Assigning numbers to enums. InvoiceStatus s = 2; fails with error CS0266; a cast (InvoiceStatus)99 compiles but holds a value with no name. Fix: use members, and check Enum.IsDefined for values from outside.
  4. Large or changing data in structs. Every assignment copies it all. Fix: structs for small, unchanging values only.
  5. Trying to change a record. year.Start = ... fails with error CS8852 (init-only). Fix: make a copy with with.

In real projects​

From 16 years of .NET projects

A fee report once showed zero partially paid invoices for a whole term. The query in C# used the string "Partial", while the database stored "PartiallyPaid". The code had compiled and passed review.

After that, every status in the system became an enum, with names copied from the database constraints. The same bug cannot be written any more; it fails the build.

I also ask in reviews: "What does 0 mean for this enum?" It is a two-second question that has caught real bugs.

Best practices​

  • Use an enum for every fixed list of values, such as statuses, types, and modes.
  • Match enum names exactly to the database's allowed values.
  • Make the 0 member a safe default, or require the value explicitly.
  • Use structs only for small, immutable values; use classes for entities.
  • Use records for data that should compare by value: DTOs, requests, results.

Recap​

  • An enum is a fixed list of named values, stored as numbers underneath.
  • Enum names matching the CHECK constraints convert to and from database text.
  • An unset enum holds 0, even if no member has that value.
  • A struct is a value type: assignment copies the whole value.
  • A record compares by value, prints its properties, and copies with with.
  • DateRange is a readonly record struct: a small, immutable value that compares by value.

Homework​

Library module task: Write a CopyStatus enum matching CK_BookCopies_Status (Available, Issued, Lost), and print whether each status can be issued.

Then write a BookInfo record with ISBN, title, and author. Show that two records with the same values are equal, and use with to correct a title without changing the original.

A correct result looks like:

Output
Available (stored as 'Available') can be issued
Issued (stored as 'Issued') cannot be issued
Lost (stored as 'Lost') cannot be issued
Supplier and catalog match: True
Corrected: BookInfo { Isbn = 9780143039655, Title = Malgudi Days (Penguin Classics), Author = R. K. Narayan }
Original unchanged: Malgudi Days
Show the solution
library-assignment/043-library-enum-record.cs
// Library homework, Lesson 43: an enum for copy status, and a record for book details.
// Run with: dotnet run library-assignment/043-library-enum-record.cs
CopyStatus[] statuses = { CopyStatus.Available, CopyStatus.Issued, CopyStatus.Lost };
foreach (CopyStatus status in statuses)
{
string canIssue = status == CopyStatus.Available ? "can be issued" : "cannot be issued";
Console.WriteLine($"{status,-9} (stored as '{status}') {canIssue}");
}

BookInfo fromSupplier = new BookInfo("9780143039655", "Malgudi Days", "R. K. Narayan");
BookInfo inCatalog = new BookInfo("9780143039655", "Malgudi Days", "R. K. Narayan");
BookInfo corrected = fromSupplier with { Title = "Malgudi Days (Penguin Classics)" };

Console.WriteLine($"Supplier and catalog match: {fromSupplier == inCatalog}");
Console.WriteLine($"Corrected: {corrected}");
Console.WriteLine($"Original unchanged: {fromSupplier.Title}");

// Names match CK_BookCopies_Status in SchoolDb.
enum CopyStatus
{
Available,
Issued,
Lost
}

record BookInfo(string Isbn, string Title, string Author);

{status,-9} and {status} both call ToString() on the enum, which returns the member's name: exactly the text stored in BookCopies.Status.

Quiz​

  1. Why use an enum instead of a status string?

    1. Enums are faster to type
    2. The compiler rejects invalid values and typos
    3. Databases require enums
    4. Strings cannot be compared
    Show answer

    Answer: B. The compiler rejects invalid values and typos Only the listed members are valid, so mistakes fail the build.

  2. What does InvoiceStatus.PartiallyPaid.ToString() return?

    1. 1
    2. PartiallyPaid
    3. Partially Paid
    4. InvoiceStatus
    Show answer

    Answer: B. PartiallyPaid ToString returns the member's name, which matches the database value.

  3. A Student's Gender is never set. What does it hold?

    1. Gender.Male
    2. null
    3. 0, which is not a defined Gender
    4. An exception
    Show answer

    Answer: C. 0, which is not a defined Gender Enums default to 0, whether or not a member has that value.

  4. What does assignment do with a struct?

    1. Copies a reference
    2. Copies the whole value
    3. Creates nothing
    4. Makes it null
    Show answer

    Answer: B. Copies the whole value Structs are value types; each variable has its own copy.

  5. Two records have identical values. What does == return?

    1. False, they are different objects
    2. True, records compare by value
    3. An error
    4. Only true for record structs
    Show answer

    Answer: B. True, records compare by value Value equality is one of the things a record adds.

Interview questions​

Questions are grouped by experience level. Click a question to see a model answer. All questions: OOP interview questions.

Fresher (0-2 yrs)​

F What is an enum, and why use one? concept

A type with a fixed list of named values, such as InvoiceStatus { Unpaid, PartiallyPaid, Paid, Cancelled }. It replaces magic strings: invalid values and typos fail the build, and editors list the valid options.

See the example · Permalink

F What is a record in C#? concept

A type, written in one line such as record MarkEntry(int EnrollmentId, int ExamSubjectId, decimal? MarksObtained), with init-only properties, value equality, a readable ToString(), and with expressions for copies with changes.

See the example · Permalink

F How do you convert an enum to and from a string? coding

status.ToString() returns the member name, such as "PartiallyPaid". Enum.Parse<InvoiceStatus>("Paid") converts text back, and Enum.TryParse does it safely, returning false for unknown text.

See the example · Permalink

Mid (3-5 yrs)​

M What is the difference between a struct and a class? concept

A struct is a value type: assignment copies the whole value, it cannot be null (unless nullable), and it cannot inherit. A class is a reference type: assignment copies a reference to one shared object. Use structs for small immutable values, classes for entities.

See the example · Permalink

M What is the default value of an enum, and why can it be a problem? concept

0, whether or not a member has that value. If no member is 0, an unset enum field holds an undefined value, as with Gender. Make 0 a safe default, or require the value in the constructor, and use Enum.IsDefined for values from outside.

See the example · Permalink

M What does a with expression do? coding

It creates a copy of a record (or struct) with some properties changed, leaving the original untouched: entered with { MarksObtained = 90m }. It is how you "change" immutable data.

See the example · Permalink

Senior (6-9 yrs)​

S A report shows zero 'partial' invoices because C# used the string 'Partial' and the database stores 'PartiallyPaid'. How do you prevent this class of bug? scenario

Replace status strings with an enum whose member names match the database CHECK constraint exactly, and convert with ToString() and Enum.Parse. Typos then fail the build, and a test can check that every enum name is allowed by the constraint.

How to answer: Explain the root cause (unchecked strings), the fix (enums matching the schema), and an automated guard (a test comparing enum names with the CHECK constraint values).

See the example · Permalink

S Should entities like Student be records? design

Usually not. Entities have identity: two students with identical names and birth dates are still different people, distinguished by Id, and they change over time. Records compare by value and are meant to stay fixed, which suits DTOs, messages, and value objects.

How to answer: Contrast identity equality with value equality, and mutability with immutability. Mention that ORMs track entities by key, which value equality can confuse.

See the example · Permalink

Architect / Lead (10+ yrs)​

A How would you choose between class, struct, record, and record struct across a codebase? design

Classes for entities with identity and behavior; records for data passed between layers and compared by value; readonly record structs for tiny, frequently created values; enums for fixed option lists. Document the convention so the whole team chooses the same way.

How to answer: Give the decision criteria (identity, mutability, size, equality semantics) and the costs of wrong choices, such as large struct copies or entities compared by value.

See the example · Permalink

Resources​

  • Code for this lesson: git tag lesson-43-enums-structs-records (code repository coming soon)
  • Files: src/SchoolApp.Core/Enums/InvoiceStatus.cs, src/SchoolApp.Core/Enums/Gender.cs, src/SchoolApp.Core/Common/DateRange.cs, src/SchoolApp.Core/Entities/FeeInvoice.cs, src/SchoolApp.Console/Program.cs, library-assignment/043-library-enum-record.cs
  • Video: coming soon

What's next​

You now have every kind of type the SMS needs. Next: one C# class for every table in SchoolDb, with matching types, nullability, enums, and links between them. Lesson 44: Model classes for every table

Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.