Enums, Structs and Records: Statuses and Values – C# | Lesson 43
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
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:
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
| Class | Struct | |
|---|---|---|
| Kind | Reference type | Value type |
| Assignment copies | The reference: both variables share one object | The whole value: two independent copies |
Can be null | Yes | No (unless Struct?) |
| Inheritance | Yes | No |
| Best for | Entities with identity and behaviour: Student, FeeInvoice | Small 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
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 you | Meaning |
|---|---|
| Value equality | Two records with the same values are == |
with expressions | A 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:
namespace SchoolApp.Core.Enums;
// Lesson 43: names match CK_FeeInvoices_Status.
public enum InvoiceStatus
{
Unpaid,
PartiallyPaid,
Paid,
Cancelled
}
Show the other six enums
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'
}
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
}
namespace SchoolApp.Core.Enums;
// Lesson 43: names match CK_AttendanceRecords_Status.
public enum AttendanceStatus
{
Present,
Absent,
Late,
Leave
}
namespace SchoolApp.Core.Enums;
// Lesson 43: names match CK_Exams_ExamType.
public enum ExamType
{
UnitTest,
Term,
Annual
}
namespace SchoolApp.Core.Enums;
// Lesson 43: names match CK_FeeStructures_FeeType.
public enum FeeType
{
Tuition,
Transport,
Library,
Exam
}
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
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;
}
}
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:
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
// 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);
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:
| Direction | Code | Result |
|---|---|---|
| C# to SQL | InvoiceStatus.PartiallyPaid.ToString() | "PartiallyPaid" |
| SQL to C# | Enum.Parse<InvoiceStatus>("Paid") | InvoiceStatus.Paid |
| Unknown text | Enum.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 data | Equal when | Type |
|---|---|---|
| An entity, such as a student or an invoice | It is the same record: same Id | Class |
| A value, such as a date range or a mark entry | All values are the same | Record 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 need | Choose | SMS example |
|---|---|---|
| A fixed list of options | enum | InvoiceStatus, PaymentMode |
| Something with identity that changes over time | class | Student, FeeInvoice |
| Data passed between layers, compared by value | record | MarkEntry, DTOs in Lesson 45 |
| A tiny, immutable value used often | readonly record struct | DateRange |
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
- Enum names that differ from the database values. Parsing fails at run time. Fix: copy names exactly from the CHECK constraint.
- 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.
- Assigning numbers to enums.
InvoiceStatus s = 2;fails witherror CS0266; a cast(InvoiceStatus)99compiles but holds a value with no name. Fix: use members, and checkEnum.IsDefinedfor values from outside. - Large or changing data in structs. Every assignment copies it all. Fix: structs for small, unchanging values only.
- Trying to change a record.
year.Start = ...fails witherror CS8852(init-only). Fix: make a copy withwith.
In real 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. DateRangeis areadonly 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:
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 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
Why use an enum instead of a status string?
Show answer
Answer: B. The compiler rejects invalid values and typos Only the listed members are valid, so mistakes fail the build.
What does InvoiceStatus.PartiallyPaid.ToString() return?
Show answer
Answer: B. PartiallyPaid ToString returns the member's name, which matches the database value.
A Student's Gender is never set. What does it hold?
Show answer
Answer: C. 0, which is not a defined Gender Enums default to 0, whether or not a member has that value.
What does assignment do with a struct?
Show answer
Answer: B. Copies the whole value Structs are value types; each variable has its own copy.
Two records have identical values. What does == return?
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.