> "The record is the natural way to represent data that belongs together — not because it has the same type, but because it describes the same thing."
In This Chapter
- 11.1 Beyond Parallel Arrays: The Need for Records
- 11.2 Defining Record Types
- 11.3 Working with Records
- 11.4 The WITH Statement
- 11.5 Arrays of Records
- 11.6 Nested Records
- 11.7 Variant Records
- 11.8 Records as Parameters
- 11.9 Modeling Real Data with Records
- 11.10 Project Checkpoint: PennyWise Records
- 11.11 Chapter Summary
Chapter 11: Records and Variant Records — Structured Data
"The record is the natural way to represent data that belongs together — not because it has the same type, but because it describes the same thing." — Niklaus Wirth, Algorithms + Data Structures = Programs (1976)
In the previous two chapters, we learned how arrays let us store collections of values under a single name. Arrays are powerful, but they come with a fundamental constraint: every element must be the same type. A list of integers, a list of strings, a list of reals — arrays handle these beautifully. But what happens when we need to represent something more complex? A student has a name (a string), an ID number (an integer), a GPA (a real number), and an enrollment status (a boolean). No single array can hold all of that simultaneously.
This chapter introduces the record, Pascal's mechanism for grouping related data of different types into a single, named structure. If arrays are like columns in a spreadsheet (many values, all the same type), then records are like rows (one entry, multiple types). Records are one of the most important features in Pascal — indeed, Wirth considered them so fundamental that they appear prominently in the title of his famous book, Algorithms + Data Structures = Programs. Records let us model real-world entities as coherent units rather than scattered variables, and they make our programs dramatically easier to write, understand, and maintain.
We will also explore variant records, Pascal's elegant approach to data that can take on different forms depending on context. A game item might be a weapon (with a damage value), a potion (with a heal amount), or a key (with a door ID). Variant records let us represent all of these with a single type. And we will see how records combine with arrays to create powerful data collections that form the backbone of most real programs.
By the end of this chapter, you will be able to:
- Define record types with multiple fields of different types
- Access record fields using dot notation and the
WITHstatement - Create arrays of records for structured data collections
- Use variant records (tagged unions) for polymorphic data
- Nest records within records for complex data modeling
11.1 Beyond Parallel Arrays: The Need for Records
Let us start with a concrete problem. Suppose we are building GradeBook Pro and need to track information about students in a class. With what we know so far — variables, arrays, and procedures — we might try something like this:
var
Names: array[1..30] of string;
IDs: array[1..30] of Integer;
GPAs: array[1..30] of Real;
Enrolled: array[1..30] of Boolean;
NumStudents: Integer;
This approach — using multiple arrays where index i in each array refers to the same student — is called parallel arrays. Student 5's name is in Names[5], their ID is in IDs[5], their GPA is in GPAs[5], and their enrollment status is in Enrolled[5]. The arrays run in parallel, linked only by their shared index.
Parallel arrays work, technically. But they have serious problems that become worse as programs grow:
Problem 1: Fragile coupling. The only thing connecting Names[5], IDs[5], GPAs[5], and Enrolled[5] is the index 5. There is no language-level guarantee that these four values belong together. If we write a sorting procedure that rearranges the Names array alphabetically but forget to perform the same rearrangement on IDs, GPAs, and Enrolled, the data becomes silently corrupt. Student 5's name might now be "Alice" but her GPA belongs to "Zach." The compiler cannot catch this error because, as far as it knows, these are four independent arrays.
Consider what happens when we try to sort students by GPA:
{ Sorting with parallel arrays — error-prone! }
procedure SortByGPA_ParallelArrays;
var
I, J: Integer;
TempName: string;
TempID: Integer;
TempGPA: Real;
TempEnrolled: Boolean;
begin
for I := 1 to NumStudents - 1 do
for J := 1 to NumStudents - I do
if GPAs[J] < GPAs[J + 1] then
begin
{ Must swap ALL four arrays in sync }
TempName := Names[J];
Names[J] := Names[J + 1];
Names[J + 1] := TempName;
TempID := IDs[J];
IDs[J] := IDs[J + 1];
IDs[J + 1] := TempID;
TempGPA := GPAs[J];
GPAs[J] := GPAs[J + 1];
GPAs[J + 1] := TempGPA;
TempEnrolled := Enrolled[J];
Enrolled[J] := Enrolled[J + 1];
Enrolled[J + 1] := TempEnrolled;
end;
end;
That is sixteen lines of swap code for four fields. If we add a fifth field (say, email address), we must add four more swap lines — and if we forget, the data is silently scrambled.
Problem 2: Passing data is awkward. If we want to write a procedure that prints a student's information, we must pass four separate parameters:
procedure PrintStudent(Name: string; ID: Integer; GPA: Real; Active: Boolean);
begin
WriteLn('Name: ', Name);
WriteLn('ID: ', ID);
WriteLn('GPA: ', GPA:0:2);
if Active then WriteLn('Status: Active')
else WriteLn('Status: Inactive');
end;
Every time we add a field to our student data, we must update the signature of every procedure that works with students. In a real program, that might be dozens of procedures.
Problem 3: Returning data is impossible. A function can only return one value. We cannot write a function that returns "a student" because there is no single type that represents a student. We are forced to use var parameters instead — a clumsy workaround that makes the code harder to read.
Problem 4: It does not scale. With four fields, parallel arrays are annoying. With ten fields, they are painful. With twenty fields (name, ID, email, phone, address, enrollment date, major, minor, advisor, GPA, credits earned, credits attempted, academic standing, financial aid status, housing assignment, meal plan, emergency contact name, emergency contact phone, graduation year, honors status), they are unmaintainable. Every operation that involves a "student" must coordinate twenty separate arrays.
What we need is a way to define a new type — TStudent — that bundles all of a student's data into a single unit. A single variable of this type would hold all the information about one student. An array of this type would hold a roster of students. Procedures could accept or return a single TStudent parameter instead of a parade of separate values.
That is exactly what records provide.
💡 Theme Connection — Algorithms + Data Structures = Programs: Wirth's famous equation reminds us that choosing the right data structure is half the battle. Records let us structure data to match the problem domain, making algorithms clearer and programs more maintainable. The sorting code above is complicated not because sorting is inherently hard, but because the data structure (parallel arrays) fights against the operation we want to perform.
11.2 Defining Record Types
A record is a composite data type that groups together a fixed collection of fields, where each field has its own name and type. Unlike an array, where all elements share the same type and are accessed by numeric index, a record's fields can each be a different type and are accessed by name. We define records in the type section of our program:
type
TStudent = record
Name: string;
ID: Integer;
GPA: Real;
Enrolled: Boolean;
end;
Let us break this down piece by piece:
TStudentis the name of our new type. By Free Pascal convention, user-defined type names begin withT(for "Type"). This prefix is not required by the language —StudentRecordorStudentwould compile just fine — but it is a universal convention in Pascal programming that instantly tells readers "this is a type name, not a variable name." Following conventions like this makes code easier to read for everyone in the Pascal community.recordbegins the record definition.- Each line inside declares a field: a name, a colon, and a type. These look similar to variable declarations, and that is intentional — each field is essentially a variable that lives inside the record.
endcloses the record definition. Note the semicolon afterend— it terminates the type definition.- Fields can be of any type — integers, reals, strings, booleans, arrays, enumerated types, sets, or even other records (which we call nesting, covered in Section 11.6).
Once we have defined the type, we can declare variables of that type in the var section:
var
Student1: TStudent;
Student2: TStudent;
ClassPresident: TStudent;
Each of these variables contains all four fields. The variable Student1 is a single entity that holds a name, an ID, a GPA, and an enrollment status — all bundled together. You can think of it as a little container with four labeled compartments, each holding a different kind of data.
It is important to understand that TStudent is a type, not a variable. You cannot store data in TStudent itself, just as you cannot store data in Integer itself. The type describes the shape of the data; variables of that type hold the actual data.
Combining Field Declarations
Multiple fields of the same type can share a declaration, just like variables:
type
TPoint = record
X, Y: Real; { Both X and Y are Real }
end;
TDateRec = record
Day, Month, Year: Integer; { All three are Integer }
end;
This is purely a syntactic convenience — X, Y: Real is identical in meaning to declaring X: Real and Y: Real on separate lines. Use whichever form is clearer for your particular record.
Fields of Complex Types
Fields can be of any previously defined type, including arrays and other user-defined types:
type
TGradeArray = array[1..10] of Integer;
TStudent = record
Name: string;
ID: Integer;
Grades: TGradeArray; { An array inside a record }
NumGrades: Integer; { How many grades are stored }
GPA: Real;
Enrolled: Boolean;
end;
Now each TStudent variable carries its own array of up to 10 grades, along with a counter that tracks how many grades have been entered. This is far more organized than having a separate grades array for every student.
⚠️ Order matters in type definitions. If a record uses a type like
TGradeArray, that type must be defined before the record. Free Pascal processes types in top-to-bottom declaration order within atypeblock. If you try to use a type before it is defined, you will get a compilation error.
Initializing Records with Typed Constants
You can assign values to a record field by field after declaration (we will see how in the next section), or you can use a typed constant to create a pre-initialized record:
const
DefaultStudent: TStudent = (
Name: 'Unnamed';
ID: 0;
GPA: 0.0;
Enrolled: False
);
This syntax uses parentheses with field-name/value pairs separated by semicolons. Each field name is followed by a colon and its initial value. This syntax is only valid in const or typed const declarations — you cannot use it in regular assignment statements.
Typed constants are useful for providing default values. For example, when you add a new student to GradeBook Pro, you might start with a copy of DefaultStudent and then fill in the actual values:
var
NewStudent: TStudent;
begin
NewStudent := DefaultStudent; { Start with defaults }
NewStudent.Name := 'Alice Chen';
NewStudent.ID := 10042;
{ GPA and Enrolled keep their default values }
end;
How Records Differ from Arrays
It is worth pausing to compare records and arrays explicitly, since both are composite types:
| Feature | Array | Record |
|---|---|---|
| Element types | All the same | Can be different |
| Access method | By numeric index (A[3]) |
By field name (R.Name) |
| Size flexibility | Fixed at compile time (static) or runtime (dynamic) | Always fixed at compile time |
| Typical use | Collections of similar items | Bundling attributes of a single entity |
Arrays answer the question "I have many things of the same kind." Records answer the question "I have one thing with many attributes." As we will see in Section 11.5, the most powerful data structures often combine both: an array of records gives us many things, each with many attributes.
11.3 Working with Records
Dot Notation
To access a field within a record, we use dot notation: the record variable name, a period (dot), and the field name.
Student1.Name := 'Alice Chen';
Student1.ID := 10042;
Student1.GPA := 3.87;
Student1.Enrolled := True;
WriteLn('Student: ', Student1.Name);
WriteLn('GPA: ', Student1.GPA:0:2);
Each field behaves exactly like a standalone variable of its type. Student1.Name is a string — you can concatenate it, compare it, pass it to WriteLn, or do anything else you can do with any string. Student1.GPA is a Real — you can use it in arithmetic expressions, comparisons, and formatted output. Student1.Enrolled is a Boolean — you can use it in if conditions and assign True or False to it.
The dot notation reads naturally as "the Name of Student1" or "Student1's Name." This readability is one of the great advantages of records over parallel arrays.
Reading Record Fields from Input
Since each field is an ordinary variable of its type, you can use ReadLn to read values directly into record fields:
Write('Enter student name: ');
ReadLn(Student1.Name);
Write('Enter student ID: ');
ReadLn(Student1.ID);
Write('Enter GPA: ');
ReadLn(Student1.GPA);
Write('Enrolled? (True/False): ');
ReadLn(Student1.Enrolled);
This works because Student1.Name is a string variable (so ReadLn reads a string), Student1.ID is an integer variable (so ReadLn reads an integer), and so on. The record fields are fully interchangeable with standalone variables in all contexts.
Record Assignment (Whole-Record Copy)
One of the most convenient features of records is whole-record assignment. If two variables are the same record type, you can copy all fields at once with a single statement:
Student2 := Student1; { Copies ALL fields from Student1 to Student2 }
This single line is equivalent to:
Student2.Name := Student1.Name;
Student2.ID := Student1.ID;
Student2.GPA := Student1.GPA;
Student2.Enrolled := Student1.Enrolled;
But it is one statement instead of four. For a record with twenty fields, the savings in code (and the reduction in opportunities for errors) is enormous.
This is a deep copy for value types — after the assignment, Student2 is a completely independent copy of Student1. Changing Student2.Name afterward will not affect Student1.Name. They are separate variables that happen to contain the same values.
Student2 := Student1; { Copy all fields }
Student2.Name := 'Modified'; { Only Student2 is changed }
WriteLn(Student1.Name); { Prints original name, not 'Modified' }
This copy behavior is crucial to understand. It means you can freely pass records to procedures, store them in temporary variables, and swap them during sorting, without worrying about unintended side effects on the original data.
Record Comparison — A Deliberate Limitation
Unlike assignment, Pascal does not allow direct comparison of entire records:
if Student1 = Student2 then { COMPILE ERROR! }
This may seem surprising or even frustrating, but Wirth had good reasons for this design decision. Records can contain fields of types where equality is ambiguous or problematic:
- Floating-point reals: Should
3.14159equal3.14160? Exact floating-point comparison is notoriously unreliable due to rounding. - Strings: Should comparison be case-sensitive? Should trailing spaces matter?
- Arrays: Should all elements be compared, or only the "active" elements indicated by a count field?
- Padding bytes: The compiler may insert padding between fields for memory alignment. Comparing raw bytes would include these meaningless padding values.
Rather than choosing one comparison behavior that would be wrong for some use cases, Pascal requires you to define what equality means for your specific data:
if Student1.ID = Student2.ID then
WriteLn('Same student (matched by ID)');
Or write a dedicated comparison function:
function SameStudent(const A, B: TStudent): Boolean;
begin
SameStudent := (A.ID = B.ID) and (A.Name = B.Name);
end;
This function compares by ID and name. You might write a different function that compares by ID alone, or one that compares all fields. The right choice depends on your application.
💡 Design Insight: The inability to compare records directly is actually a feature in disguise. It forces you to think explicitly about what "equality" means for your data. Are two students "the same" if they have the same ID? The same name? Every single field identical? The answer depends on your domain, and different operations in the same program might need different answers.
11.4 The WITH Statement
When you work with a record's fields extensively, typing the record variable name repeatedly becomes tedious and cluttered:
Student1.Name := 'Bob Martinez';
Student1.ID := 10078;
Student1.GPA := 3.45;
Student1.Enrolled := True;
WriteLn(Student1.Name, ' (ID: ', Student1.ID, ')');
The prefix Student1. appears five times in five lines. For longer record variable names like ClassRoster[CurrentIndex], the repetition becomes even more burdensome. Pascal provides the WITH statement as a syntactic shortcut:
with Student1 do
begin
Name := 'Bob Martinez';
ID := 10078;
GPA := 3.45;
Enrolled := True;
WriteLn(Name, ' (ID: ', ID, ')');
end;
Inside the with block, field names are used without the record variable prefix. The compiler understands that Name means Student1.Name, ID means Student1.ID, and so on. The with statement essentially "opens" the record's namespace for the duration of its block.
This is particularly useful with arrays of records, where the access expression can be long:
{ Without WITH }
ClassRoster[StudentIndex].Name := 'Carol Park';
ClassRoster[StudentIndex].ID := 10055;
ClassRoster[StudentIndex].GPA := 3.78;
ClassRoster[StudentIndex].Enrolled := True;
{ With WITH }
with ClassRoster[StudentIndex] do
begin
Name := 'Carol Park';
ID := 10055;
GPA := 3.78;
Enrolled := True;
end;
The second version is noticeably easier to read — the data being assigned stands out more clearly when it is not buried in repeated prefixes.
Multiple Records in WITH
You can open multiple records in a single with:
with Student1, DateRec do
begin
Name := 'Carol Park';
Day := 15;
Month := 9;
Year := 2026;
end;
If fields from different records happen to have the same name, the last record listed takes priority. This is a source of subtle bugs, which brings us to an important discussion.
When to Avoid WITH
The WITH statement is convenient but genuinely controversial in the Pascal community. Many experienced programmers avoid it entirely or use it only in very restricted circumstances. Here are the reasons:
Scope ambiguity. If a local variable has the same name as a record field, WITH silently shadows the local variable. The record field takes priority, and the local variable becomes inaccessible within the block:
var
Name: string;
Student1: TStudent;
begin
Name := 'Program Author';
with Student1 do
begin
Name := 'Alice'; { Sets Student1.Name, NOT the local Name! }
WriteLn(Name); { Prints 'Alice' — the record field }
end;
WriteLn(Name); { Prints 'Program Author' — local was never touched }
end;
A programmer reading this code might reasonably expect Name := 'Alice' to assign to the local variable. Only by knowing the fields of TStudent can they realize it actually assigns to Student1.Name. This is a genuine trap.
Readability at distance. In a long with block — say, 20 or 30 lines — readers encounter a bare identifier like Name or Value and cannot tell whether it is a local variable, a global variable, a procedure parameter, or a record field, without scrolling up to find the with header. This forces readers to keep a mental model of the record's fields, adding cognitive burden.
Maintenance hazard. Suppose you have working code that uses with StudentRec do and also has a local variable called Email. Later, someone adds an Email field to the TStudent record type. Suddenly, within the with block, Email silently changes meaning — it now refers to the record field instead of the local variable. The compiler reports no error. The code simply does the wrong thing.
Debugging difficulty. When a bug occurs inside a with block, it can be harder to determine which record is being modified, especially if the with uses a complex expression like with AllStudents[FindByID(TargetID)].
⚠️ Our recommendation: Use
WITHonly for short blocks (3–5 lines) where the record being accessed is obvious from context and there is no risk of name collision. For longer code, or when clarity is paramount, use explicit dot notation. In professional codebases, many teams banwithentirely in their style guides.💡 Theme Connection — Simplicity: The
WITHstatement seems to simplify code by reducing keystrokes, but it can actually complicate reasoning about the code. True simplicity means code that is easy to understand and maintain, not merely short to type. A few extra characters ofStudent1.Namebuy a lot of clarity.
11.5 Arrays of Records
Arrays and records become truly powerful when combined. An array of records is a collection where each element is a complete record — a natural way to represent a table of data, much like rows in a spreadsheet or a database table.
type
TStudent = record
Name: string;
ID: Integer;
GPA: Real;
Enrolled: Boolean;
end;
var
Roster: array[1..30] of TStudent;
NumStudents: Integer; { How many students are actually stored }
Now Roster[1] is a complete TStudent (with Name, ID, GPA, and Enrolled), Roster[2] is another complete TStudent, and so on. Each element of the array is a full record, not just a single value. We access individual fields by combining array indexing with dot notation:
Roster[1].Name := 'Alice Chen';
Roster[1].ID := 10042;
Roster[1].GPA := 3.87;
Roster[1].Enrolled := True;
The expression Roster[1].Name reads as: "Go to element 1 of the Roster array, then access its Name field." This chaining of array index and dot notation is natural and readable.
Populating an Array of Records
Here is a procedure that reads student data from the keyboard into an array of records:
procedure ReadStudents(var Students: array of TStudent; var N: Integer);
var
I: Integer;
begin
Write('How many students? ');
ReadLn(N);
for I := 0 to N - 1 do
begin
WriteLn;
WriteLn('--- Student ', I + 1, ' ---');
Write(' Name: ');
ReadLn(Students[I].Name);
Write(' ID: ');
ReadLn(Students[I].ID);
Write(' GPA: ');
ReadLn(Students[I].GPA);
Students[I].Enrolled := True;
end;
end;
Notice that we pass the array using var (because we are modifying it) and use open array parameters (array of TStudent) so the procedure works with arrays of any size.
Displaying an Array of Records
A formatted display procedure might look like this:
procedure DisplayRoster(const Students: array of TStudent; N: Integer);
var
I: Integer;
begin
WriteLn;
WriteLn('Name':25, 'ID':8, 'GPA':7, 'Status':10);
WriteLn(StringOfChar('-', 50));
for I := 0 to N - 1 do
begin
Write(Students[I].Name:25);
Write(Students[I].ID:8);
Write(Students[I].GPA:7:2);
if Students[I].Enrolled then
WriteLn('Active':10)
else
WriteLn('Inactive':10);
end;
end;
We use const here because the procedure only reads the data, and we want both the efficiency benefit (no copy) and the documentation benefit (readers know the array will not be modified).
Searching an Array of Records
Searching works by iterating through the array and checking a specific field:
function FindStudentByID(const Students: array of TStudent;
N: Integer; TargetID: Integer): Integer;
{ Returns the index of the student with the given ID, or -1 if not found }
var
I: Integer;
begin
FindStudentByID := -1;
for I := 0 to N - 1 do
if Students[I].ID = TargetID then
begin
FindStudentByID := I;
Exit;
end;
end;
The function returns an index, not a record. Returning the index is typically more useful because the caller can then both read and modify the found record through the array.
We could also search by name, by GPA range, or by any other field. The flexibility of records means we can write many different search functions, each looking at different fields, all operating on the same data structure.
Sorting an Array of Records
Sorting records works exactly like sorting any array, except you must choose which field to sort by. Here is a selection sort by GPA (descending — highest GPA first):
procedure SortByGPA(var Students: array of TStudent; N: Integer);
var
I, J, MaxIdx: Integer;
Temp: TStudent;
begin
for I := 0 to N - 2 do
begin
MaxIdx := I;
for J := I + 1 to N - 1 do
if Students[J].GPA > Students[MaxIdx].GPA then
MaxIdx := J;
if MaxIdx <> I then
begin
Temp := Students[I]; { Whole-record assignment! }
Students[I] := Students[MaxIdx];
Students[MaxIdx] := Temp;
end;
end;
end;
Look at the swap code: three lines, three whole-record assignments. Compare this to the sixteen lines of parallel-array swap code in Section 11.1. That is the power of records — because all the data about a student is bundled into a single unit, swapping that unit is one operation, not four. Adding a new field to TStudent (say, Email: string) does not require changing the sort procedure at all — the whole-record assignment automatically includes the new field.
💡 Parallel Arrays vs. Arrays of Records — A Direct Comparison: With parallel arrays, sorting requires swapping elements in every array simultaneously — miss one and the data is corrupt. With an array of records, you swap entire records. The data stays together automatically because it was never apart to begin with. This is why arrays of records are almost always preferable to parallel arrays for representing collections of multi-attribute entities.
GradeBook Pro: The Class Roster
Let us see how GradeBook Pro benefits from arrays of records. Previously, student grades lived in a separate global array, disconnected from student names and IDs. Now each student carries their own grades:
type
TStudent = record
Name: string;
ID: Integer;
Grades: array[1..10] of Integer;
NumGrades: Integer;
GPA: Real;
Enrolled: Boolean;
end;
var
ClassRoster: array[1..30] of TStudent;
NumStudents: Integer;
Each student carries their own grades array and a counter for how many grades they have. Adding a new grade to student 5 is straightforward:
with ClassRoster[5] do
begin
NumGrades := NumGrades + 1;
Grades[NumGrades] := 92;
end;
This is a case where WITH is appropriate — the block is short, the record being accessed is clear, and there is no risk of name collision.
Computing an individual student's average is also simple because each student's grades are self-contained:
function StudentAverage(const S: TStudent): Real;
var
I, Total: Integer;
begin
if S.NumGrades = 0 then
begin
StudentAverage := 0.0;
Exit;
end;
Total := 0;
for I := 1 to S.NumGrades do
Total := Total + S.Grades[I];
StudentAverage := Total / S.NumGrades;
end;
Notice that this function takes a single const TStudent parameter — it does not need to know which array the student came from, or what index the student is at. It works with any TStudent, from any source. This decoupling is one of the great benefits of records: procedures and functions can operate on the concept (a student) rather than on the storage (array index 5 of five parallel arrays).
11.6 Nested Records
Records can contain other records as fields. This is called nesting, and it lets us model hierarchical data — data that has a natural tree-like structure where one entity contains sub-entities.
Consider an employee database. An employee has a name, a salary, and a hire date — but they also have a home address, which itself consists of a street, city, state, and zip code. The address is not a single value; it is a collection of related values. We model this by defining an address record and including it as a field within the employee record:
type
TAddress = record
Street: string;
City: string;
State: string;
ZipCode: string;
end;
TDateRec = record
Day: Integer;
Month: Integer;
Year: Integer;
end;
TEmployee = record
Name: string;
HomeAddress: TAddress; { Record within a record }
HireDate: TDateRec; { Another nested record }
Salary: Real;
end;
The TEmployee record has four fields, two of which are themselves records. This creates a two-level hierarchy: Employee contains Address contains Street, City, etc.
Accessing Nested Fields
Accessing nested fields requires chaining dot notation — each dot descends one level:
var
Emp: TEmployee;
begin
Emp.Name := 'Diana Torres';
Emp.HomeAddress.Street := '742 Evergreen Terrace';
Emp.HomeAddress.City := 'Springfield';
Emp.HomeAddress.State := 'IL';
Emp.HomeAddress.ZipCode := '62704';
Emp.HireDate.Day := 15;
Emp.HireDate.Month := 6;
Emp.HireDate.Year := 2024;
Emp.Salary := 67500.00;
end;
The expression Emp.HomeAddress.City reads as: "The City of the HomeAddress of Emp." The dot chain goes from the outermost record to the innermost field: first Emp (an TEmployee), then .HomeAddress (a TAddress within that employee), then .City (a string within that address).
Reusability of Nested Types
A major advantage of nested records is that the inner types are reusable. We defined TAddress once and can use it in any record that needs an address:
type
TCustomer = record
Name: string;
CustomerID: Integer;
BillingAddress: TAddress; { Same TAddress type }
ShippingAddress: TAddress; { Used twice! }
end;
The TCustomer record contains two addresses — billing and shipping — and both use the same TAddress type. We did not have to define the four address fields twice. This is the DRY principle (Don't Repeat Yourself) applied to data structures.
Similarly, TDateRec can be used anywhere a date is needed — hire dates, birth dates, order dates, and so on. Defining reusable sub-record types leads to cleaner, more consistent programs.
Assigning Nested Records
You can assign entire nested records at once:
var
Emp1, Emp2: TEmployee;
MainOffice: TAddress;
begin
MainOffice.Street := '100 Corporate Drive';
MainOffice.City := 'Austin';
MainOffice.State := 'TX';
MainOffice.ZipCode := '78701';
Emp1.HomeAddress := MainOffice; { Copies all four address fields }
Emp2.HomeAddress := MainOffice; { Both employees at same address }
end;
This is whole-record assignment applied to a nested record. All four fields of MainOffice are copied into Emp1.HomeAddress in a single statement.
Nested WITH (Use With Caution)
You can nest WITH statements to avoid repeating nested prefixes:
with Emp do
with HomeAddress do
begin
Street := '742 Evergreen Terrace';
City := 'Springfield';
end;
Or combine them in a single with:
with Emp, Emp.HomeAddress do
begin
Name := 'Diana Torres'; { Emp.Name }
Street := '742 Evergreen Terrace'; { Emp.HomeAddress.Street }
end;
However, nested WITH compounds the readability problems we discussed in Section 11.4. With two records open, the scope for name collisions doubles, and it becomes even harder for readers to determine which record owns which field. For nested records, explicit dot notation is almost always clearer and safer.
Crypts of Pascalia: Room Records
In our text adventure game, rooms are naturally modeled as nested records. Each room has a name, a description, and a set of exits — and the exits are themselves a structured collection (one value per direction):
type
TExits = record
North, South, East, West: Integer; { Room indices, 0 = no exit }
end;
TRoom = record
Name: string;
Description: string;
Exits: TExits;
ItemIndices: array[1..10] of Integer; { Indices into item array }
NumItems: Integer;
Visited: Boolean;
end;
Setting up a room uses chained dot notation for the exits:
Dungeon[1].Name := 'Entrance Hall';
Dungeon[1].Description := 'A torch-lit stone chamber. Passages lead north and east.';
Dungeon[1].Exits.North := 2; { Exit leads to room 2 }
Dungeon[1].Exits.South := 0; { No exit south }
Dungeon[1].Exits.East := 3; { Exit leads to room 3 }
Dungeon[1].Exits.West := 0; { No exit west }
Dungeon[1].NumItems := 0;
Dungeon[1].Visited := False;
The nested TExits record keeps the directional data organized within each room. We could have declared North, South, East, West: Integer directly as fields of TRoom, but grouping them in a nested record makes the conceptual structure clearer: "A room has exits, and exits have four directions." It also means we can write procedures that operate on TExits alone, independent of rooms.
11.7 Variant Records
So far, every instance of a record type has exactly the same fields. Every TStudent has a Name, ID, GPA, and Enrolled field. But sometimes we need records that share some fields but differ in others depending on a category. Pascal supports this with variant records, also called tagged unions or discriminated unions.
The Problem
Suppose we are building a geometry program that works with different shapes. Every shape has a color and a position, but the specific measurements differ:
- A circle has a radius.
- A rectangle has a width and a height.
- A triangle has a base, a height, and three side lengths.
We could define three separate record types (TCircle, TRectangle, TTriangle), but then we could not store them in a single array, pass them to a single procedure, or write generic shape-processing code. We would need three arrays, three versions of every procedure, and three separate code paths everywhere.
Alternatively, we could define one record with all fields from all shapes:
type
TShape = record
Kind: string; { 'circle', 'rectangle', 'triangle' }
X, Y: Real;
Color: string;
Radius: Real; { Only used for circles }
Width, Height: Real; { Only used for rectangles }
Base, TriHeight: Real; { Only used for triangles }
SideA, SideB, SideC: Real; { Only used for triangles }
end;
This approach wastes memory (every shape carries fields it does not use) and is error-prone (nothing prevents accidentally accessing Radius on a rectangle). We need something better.
The Solution: Variant Records
Pascal's variant records give us the best of both worlds: a single type that shares common fields while allowing different variants to have different additional fields.
type
TShapeKind = (skCircle, skRectangle, skTriangle);
TShape = record
X, Y: Real; { Position — common to all shapes }
Color: string; { Color — common to all shapes }
case Kind: TShapeKind of
skCircle: (
Radius: Real
);
skRectangle: (
Width, Height: Real
);
skTriangle: (
Base, TriHeight: Real;
SideA, SideB, SideC: Real
);
end;
Let us dissect this carefully:
- The fixed part (
X,Y,Color) comes before thecasekeyword. These fields are shared by all shapes. EveryTShapevariable has these fields, regardless of its kind. - The
case Kind: TShapeKind ofintroduces the variant part.Kindis the tag field (also called the discriminant). It is an actual field of the record — you can read it and set it just like any other field. Its type must be an ordinal type (enumerated type, integer, char, boolean, etc.). - Each variant (
skCircle,skRectangle,skTriangle) lists the fields unique to that shape, enclosed in parentheses. These are the variant fields. The parentheses are required even if a variant has only one field. - The
endat the bottom closes both thecaseand therecord. There is only oneend— this is an important syntactic detail. Beginners often try to writeend; end;(one for the case, one for the record), but this is incorrect. A variant record has only oneend. - The variant part must be the last part of the record. You cannot have fixed fields after the variant part.
Using Variant Records
The workflow for variant records follows a consistent pattern: set the tag, then use only the matching variant fields.
var
S: TShape;
begin
{ Create a circle }
S.Kind := skCircle; { Set the tag field FIRST }
S.X := 100.0;
S.Y := 200.0;
S.Color := 'Red';
S.Radius := 50.0; { Now set the variant-specific field }
{ Later, calculate area based on kind }
case S.Kind of
skCircle:
WriteLn('Area = ', Pi * Sqr(S.Radius):0:2);
skRectangle:
WriteLn('Area = ', S.Width * S.Height:0:2);
skTriangle:
WriteLn('Area = ', 0.5 * S.Base * S.TriHeight:0:2);
end;
end;
The case S.Kind of statement is the fundamental pattern for working with variant records. You check the tag field to determine which variant is active, then access only the fields belonging to that variant. This mirrors the structure of the type definition itself, making the code self-documenting.
Memory Layout
Understanding how variant records work in memory helps avoid bugs and explains some of their quirks. The compiler allocates enough memory for the largest variant. All variants share the same memory region — they overlap in memory.
TShape memory layout:
[ X ][ Y ][ Color ][ Kind ][--- variant area ----------]
^ ^
| Circle: [Radius] |
| Rect: [Width][Height] |
| Tri: [B][TH][A][B][C] |
The variant area is a single block of memory large enough to hold the triangle variant (the largest). When the shape is a circle, only the first portion of this block is meaningful (holding Radius). The rest of the block exists but contains no meaningful data.
This means:
- If you set
Kind := skCircleand assignRadius := 50.0, then changeKind := skRectangle, theWidthfield occupies the same bytes thatRadiusused. The bit pattern left over from the radius value is now interpreted as a width value — which is almost certainly wrong. - Always set the tag field before setting variant fields. When you change a record's kind, any previous variant field values become meaningless.
- Always check the tag field before reading variant fields. The
case Kind ofpattern should be used every time you access variant-specific data.
⚠️ Critical Rule: Never access variant fields that do not match the current tag value. Technically, Free Pascal will not prevent this at compile time — the compiler trusts the programmer. But accessing the wrong variant is a logic error that produces garbage data. This is one of the few areas where Pascal requires discipline rather than enforcing correctness through the type system.
Free Variant Records (Without a Tag Field)
Pascal also allows variant records without a named tag field:
type
TOverlay = record
case Integer of
0: (AsInteger: LongInt);
1: (AsBytes: array[0..3] of Byte);
end;
This is called a free union (or untagged union). The case Integer of introduces variants without storing any tag field. The variants share memory, but there is no field to indicate which interpretation is currently valid.
Free unions are used for low-level programming: type punning (reinterpreting the bytes of one type as another type), accessing individual bytes of a multi-byte value, or interfacing with C libraries. They are powerful but dangerous — without a tag field, correctness depends entirely on the programmer's discipline.
In this course, we will always use tagged variants. If you find yourself needing a free union, it is usually a sign that you are doing something low-level that warrants extra care.
An Array of Variant Records
The real power of variant records appears when we combine them with arrays:
var
Shapes: array[1..100] of TShape;
NumShapes: Integer;
Now we can store circles, rectangles, and triangles in a single array. We can iterate through them and handle each according to its kind:
for I := 1 to NumShapes do
begin
Write(I, '. ');
case Shapes[I].Kind of
skCircle:
WriteLn('Circle at (', Shapes[I].X:0:1, ', ', Shapes[I].Y:0:1,
'), radius=', Shapes[I].Radius:0:1);
skRectangle:
WriteLn('Rectangle at (', Shapes[I].X:0:1, ', ', Shapes[I].Y:0:1,
'), ', Shapes[I].Width:0:1, 'x', Shapes[I].Height:0:1);
skTriangle:
WriteLn('Triangle at (', Shapes[I].X:0:1, ', ', Shapes[I].Y:0:1,
'), base=', Shapes[I].Base:0:1);
end;
end;
This ability to have a heterogeneous collection — an array containing different "kinds" of things — is one of the most valuable patterns in programming. Without variant records, we would need three separate arrays or some clumsy workaround.
💡 Historical Note: Variant records were Pascal's approach to what modern languages call "tagged unions," "discriminated unions," "sum types," or "algebraic data types." Rust's
enumwith data, TypeScript's discriminated unions, Haskell's algebraic data types, and Swift's enums with associated values all serve a similar purpose — storing different kinds of data in a unified type. The concept originated in Pascal (building on ideas from Tony Hoare) and remains one of the most important ideas in type system design.
Crypts of Pascalia: Item Types
In our adventure game, items are a perfect use case for variant records. All items share a name, description, and weight, but different kinds of items have different relevant data:
type
TItemKind = (ikWeapon, ikPotion, ikKey, ikTreasure);
TItem = record
Name: string;
Description: string;
Weight: Real;
case Kind: TItemKind of
ikWeapon: (
Damage: Integer;
Durability: Integer
);
ikPotion: (
HealAmount: Integer;
Duration: Integer { Turns the effect lasts }
);
ikKey: (
KeyID: Integer { Which door this opens }
);
ikTreasure: (
GoldValue: Integer
);
end;
Now a single TItem type can represent any item in the game, and a single Items array can hold the entire game's inventory — swords, potions, keys, and gold chalices all in one place. When the player uses an item, the code checks Kind and behaves accordingly:
case Items[I].Kind of
ikWeapon: WriteLn('You equip the ', Items[I].Name, '.');
ikPotion: WriteLn('You drink the ', Items[I].Name, ' and heal ',
Items[I].HealAmount, ' HP.');
ikKey: WriteLn('You cannot use a key directly. Approach a locked door.');
ikTreasure: WriteLn('You appraise the ', Items[I].Name, ' at ',
Items[I].GoldValue, ' gold.');
end;
11.8 Records as Parameters
Records can be passed to procedures and functions just like any other type. The three familiar passing modes all apply, but the choice between them matters more for records than for simple types because records can be large.
Pass by Value
procedure PrintStudent(Student: TStudent);
begin
WriteLn('Name: ', Student.Name);
WriteLn('ID: ', Student.ID);
WriteLn('GPA: ', Student.GPA:0:2);
end;
When passed by value, the entire record is copied onto the stack. The procedure works with its own private copy, so changes inside the procedure do not affect the caller's record. This is safe but can be expensive for large records — if TStudent contains a 50-element grades array and a 255-character name string, that is a lot of data to copy every time the procedure is called.
Pass by Reference (var)
procedure UpdateGPA(var Student: TStudent; NewGPA: Real);
begin
Student.GPA := NewGPA;
end;
When passed by reference (var), no copy is made. The procedure receives a reference (internally, a pointer) to the caller's record and operates directly on it. Use var when the procedure needs to modify the record. This is both efficient (no copy) and necessary (changes must be visible to the caller).
Pass by Const Reference
procedure PrintStudent(const Student: TStudent);
begin
WriteLn('Name: ', Student.Name);
WriteLn('ID: ', Student.ID);
WriteLn('GPA: ', Student.GPA:0:2);
end;
Using const tells the compiler two things: the procedure will not modify the record, and the compiler is free to pass it by reference internally for efficiency. This gives you the efficiency of pass-by-reference (no copy) with the safety of pass-by-value (the compiler enforces that no modification occurs).
💡 Best Practice: Use
constfor record parameters whenever the procedure or function does not need to modify the record. This is the default choice for records. Usevaronly when modification is needed. Use plain pass-by-value only when you specifically need a local copy (which is rare).
Spaced Review — From Chapter 7
When should you use a const parameter instead of a value parameter?
Use const when the subprogram needs to read but not modify the argument. For simple types (integers, reals, booleans), the performance difference is negligible — an integer fits in a register regardless. For large types like records, strings, and arrays, const avoids an expensive memory copy. Beyond performance, const serves as documentation: it tells future readers "this parameter is input-only, guaranteed not modified." This communication benefit alone justifies using const even when performance is not a concern.
Functions Returning Records
Functions can return record types, which elegantly solves the "multiple return values" problem we identified at the start of this chapter:
function CreateStudent(AName: string; AID: Integer; AGPA: Real): TStudent;
begin
CreateStudent.Name := AName;
CreateStudent.ID := AID;
CreateStudent.GPA := AGPA;
CreateStudent.Enrolled := True;
end;
Usage:
var
NewStudent: TStudent;
begin
NewStudent := CreateStudent('Eve Nakamura', 10099, 3.92);
PrintStudent(NewStudent);
end;
This is much cleaner than the pre-records alternative of using four var parameters to simulate multiple return values. The function name says "create a student," and it returns exactly that — a complete student record. Functions like this are sometimes called factory functions because they construct and return a complex value.
The A prefix on parameter names (AName, AID, AGPA) is a common Pascal convention to distinguish parameters from record fields when they have similar names. Without the prefix, writing Name := Name inside the function body would be ambiguous.
11.9 Modeling Real Data with Records
Designing good record types is a skill that goes beyond syntax. It requires thinking about the problem domain, anticipating future needs, and balancing competing concerns. Here are guidelines that will serve you well.
Guideline 1: One Record Type per Concept
Each record type should represent a single, coherent concept. A TStudent should hold student data. A TCourse should hold course data. Do not mix unrelated concepts into one record.
{ Good: Separate concepts, separate types }
type
TCourse = record
Code: string;
Title: string;
Credits: Integer;
end;
TEnrollment = record
CourseCode: string;
Grade: Char;
end;
TStudent = record
Name: string;
ID: Integer;
Enrollments: array[1..8] of TEnrollment;
NumEnrollments: Integer;
end;
This three-type design clearly separates the concepts: what a course is, what a student is, and the relationship between them (enrollment). Each type can be understood, modified, and tested independently.
Guideline 2: Choose Field Types Carefully
The type you choose for each field affects correctness, efficiency, and usability:
- Use enumerated types instead of strings for fields with a fixed set of values:
type
TLetterGrade = (grA, grB, grC, grD, grF, grIncomplete, grWithdraw);
This prevents typos like 'a' vs. 'A' vs. 'Grade A' and enables the compiler to check that case statements are exhaustive.
- Use integers for identifiers and counts. Integer comparison is fast and unambiguous — perfect for searching and sorting.
- Use reals for measurements and amounts. For financial applications where exact arithmetic matters, consider using integer cents (e.g.,
PriceCents: Integerstoring 1999 for $19.99) to avoid floating-point rounding issues. - Use strings for free-text data like names and descriptions.
- Use booleans for binary states (
Enrolled,Active,Completed). - Use short strings (
string[N]) when you know the maximum length and want to save memory, especially in large arrays of records.
Guideline 3: Include a Count Field for Partially-Used Arrays
When a record contains an array that may not be fully used, include a companion field that tracks how many elements are active:
type
TStudent = record
Name: string;
Grades: array[1..50] of Integer;
NumGrades: Integer; { How many entries in Grades are valid }
end;
Without NumGrades, there is no way to know where the meaningful data in Grades ends and the uninitialized garbage begins. This is a pattern we have seen repeatedly, and it is worth making a habit.
Guideline 4: Consider What Operations You Will Perform
Before finalizing a record design, think about what you will do with the data:
- Searching: Will you search by a specific field? Integer fields are faster and more reliable to compare than strings. Consider including a unique integer ID even if the entity has a natural string identifier (like a name).
- Sorting: Will you sort by multiple criteria? Make sure the sort keys are directly accessible fields, not buried inside nested records.
- Display: Will you display the data in formatted tables? Consider the string lengths that will be needed for clean column alignment.
- Persistence: Will you save the data to a file? Avoid dynamic-length fields (like AnsiString) if you plan to use typed files, which require fixed-size records. (We will cover this in detail in Chapter 12.)
- Comparison: Define what "equality" means for your entity early, and write a comparison function if needed.
Guideline 5: Name Fields Clearly and Unambiguously
Field names should be meaningful even without the record name as context. Remember that inside a WITH block, or when someone reads your code, the field name must stand on its own:
{ Ambiguous }
type
TOrder = record
Date: string; { Order date? Ship date? Delivery date? }
Amount: Real; { Quantity? Dollar amount? Weight? }
end;
{ Clear }
type
TOrder = record
OrderDate: string;
ShipDate: string;
TotalCost: Real;
ItemCount: Integer;
end;
The second version requires no comments — the field names are self-documenting. This is especially important in large programs where the record definition and its usage may be hundreds of lines apart.
💡 Theme Connection — Simplicity: Good record design makes the rest of your program simpler. When data is well-structured, algorithms become straightforward — "find the student with the highest GPA" is a simple loop over a well-designed array of records. When data is poorly structured, even simple operations become complex, error-prone, and hard to modify.
11.10 Project Checkpoint: PennyWise Records
It is time to bring records to our PennyWise expense tracker. Until now, our expense data has been stored in parallel arrays or individual variables — amounts in one place, categories in another, dates scattered elsewhere. Records give us a clean, unified foundation that will serve PennyWise well as it continues to grow.
Step 1: Define the Expense Record
type
TExpenseRec = record
Amount: Real;
Category: string;
Description: string;
Date: string; { Format: 'YYYY-MM-DD' }
end;
Each expense is now a single, self-contained unit. The amount, category, description, and date all travel together. There is no way for them to become separated or misaligned. If we sort expenses by date, the entire expense (including its amount and description) moves together.
Why string for Date instead of a TDateRec? The 'YYYY-MM-DD' format has a useful property: string comparison gives correct chronological ordering. '2026-03-15' < '2026-04-01' is true, so we can sort by date using plain string comparison. This keeps things simple for now; we might switch to a proper date record later if we need to do date arithmetic.
Step 2: Define the Category Record
We also want to track category-level information — specifically, budgets:
type
TCategoryRec = record
Name: string;
Budget: Real; { Monthly budget for this category }
TotalSpent: Real; { Running total of expenses }
end;
With this record, we can track not just what categories exist, but how much money is allocated to each category and how much has been spent so far. The difference between Budget and TotalSpent tells us whether we are over or under budget.
Step 3: Set Up Data Storage
const
MAX_EXPENSES = 500;
MAX_CATEGORIES = 20;
var
Expenses: array[1..MAX_EXPENSES] of TExpenseRec;
NumExpenses: Integer;
Categories: array[1..MAX_CATEGORIES] of TCategoryRec;
NumCategories: Integer;
We use static arrays with generous upper bounds. MAX_EXPENSES at 500 is enough for over a year of daily expense tracking. MAX_CATEGORIES at 20 covers typical household categories (Food, Transport, Housing, Utilities, Entertainment, etc.) with room to spare.
Step 4: Core Operations
Adding an expense:
procedure AddExpense(var Exps: array of TExpenseRec;
var Count: Integer;
NewAmount: Real;
const NewCategory, NewDesc, NewDate: string);
begin
if Count >= MAX_EXPENSES then
begin
WriteLn('Error: Expense list is full.');
Exit;
end;
Exps[Count].Amount := NewAmount;
Exps[Count].Category := NewCategory;
Exps[Count].Description := NewDesc;
Exps[Count].Date := NewDate;
Inc(Count);
end;
Notice how every field of the expense is set in one place. Compare this to the parallel-array version, where we would need four separate assignment lines to four separate arrays, plus the constant risk of getting an index wrong.
Calculating total by category:
function TotalByCategory(const Exps: array of TExpenseRec;
Count: Integer;
const CatName: string): Real;
var
I: Integer;
Total: Real;
begin
Total := 0.0;
for I := 0 to Count - 1 do
if Exps[I].Category = CatName then
Total := Total + Exps[I].Amount;
TotalByCategory := Total;
end;
This function iterates through the expenses and sums those matching the given category. The const on both the array and the category name signals that this is a pure read-only operation.
Displaying expenses in a formatted table:
procedure DisplayExpenses(const Exps: array of TExpenseRec;
Count: Integer);
var
I: Integer;
begin
WriteLn('Date':12, 'Category':15, 'Amount':10, ' Description');
WriteLn(StringOfChar('-', 60));
for I := 0 to Count - 1 do
with Exps[I] do
WriteLn(Date:12, Category:15, Amount:10:2, ' ', Description);
end;
The with Exps[I] do shortcut is appropriate here — the block is a single line, and the context is clear. Without WITH, each line of the loop body would need to repeat Exps[I]., making the formatting harder to read.
Budget report:
procedure PrintBudgetReport(const Cats: array of TCategoryRec;
NCats: Integer);
var
I: Integer;
Remaining: Real;
begin
WriteLn('Category':15, 'Budget':10, 'Spent':10, 'Remaining':12);
WriteLn(StringOfChar('-', 47));
for I := 0 to NCats - 1 do
begin
Remaining := Cats[I].Budget - Cats[I].TotalSpent;
Write(Cats[I].Name:15, Cats[I].Budget:10:2, Cats[I].TotalSpent:10:2);
Write(Remaining:12:2);
if (Cats[I].Budget > 0) and (Remaining < 0) then
Write(' ** OVER **');
WriteLn;
end;
end;
This report shows each category's budget, spending, and remaining balance, with a warning flag for categories that have exceeded their budget.
What We Gained
Compare the record-based design to the parallel arrays approach:
| Aspect | Parallel Arrays | Array of Records |
|---|---|---|
| Data access | Amounts[i], Categories[i], Dates[i], Descriptions[i] |
Expenses[i].Amount, .Category, .Date, .Description |
| Sorting | Must swap 4 arrays in sync | Swap 1 record |
| Procedure parameters | Pass 4 arrays + count | Pass 1 array + count |
| Adding a field | Add a new array; update every procedure | Add one line to record definition |
| Data integrity | No guarantee fields stay synchronized | Fields are physically bundled |
| Returning data | Cannot return "an expense" from a function | Return a TExpenseRec naturally |
The record-based design is cleaner, safer, and dramatically easier to extend. This is a permanent improvement to PennyWise's architecture — one that will pay dividends in every future chapter.
✅ PennyWise Milestone: Your expense tracker now uses structured records (
TExpenseRecandTCategoryRec) instead of parallel arrays. The data is organized, the procedures are clean, and extending the system is straightforward. In Chapter 12, we will learn to save these records to files so expenses persist between sessions — no more losing data when the program exits.
Spaced Review — From Chapter 9
What is the difference between a static array and a dynamic array?
A static array has its size determined at compile time (e.g., array[1..100] of Integer). The memory is allocated when the variable comes into scope and always occupies the same amount of space, whether you use 1 element or 100. A dynamic array (e.g., array of Integer, sized at runtime with SetLength) can grow or shrink as needed. Static arrays are simpler, require no memory management, and are slightly faster to access. Dynamic arrays are necessary when the data size is genuinely unknown at compile time. For PennyWise's expense list, a static array with a generous upper bound (500) is a pragmatic choice — simple and sufficient.
11.11 Chapter Summary
Records are one of Pascal's most important features — arguably the most important feature for practical programming. They transform how we organize data. Instead of scattering related values across disconnected variables or fragile parallel arrays, we bundle them into coherent units that mirror the structure of the real world. A student record is a student. An expense record is an expense. A game item record is an item. The code reads like the problem description because the data structures match the domain.
Key concepts from this chapter:
-
Records group fields of different types under a single name. Define them in the
typesection withrecord ... end. TheTprefix on type names is a universal Pascal convention. -
Dot notation (
RecordVar.FieldName) accesses individual fields. Each field behaves exactly like a standalone variable of its declared type — it can be assigned, read, passed to procedures, used in expressions, and read from input. -
Whole-record assignment (
Rec2 := Rec1) copies all fields at once, creating an independent copy for value types. This makes swapping, copying, and initializing records convenient and safe. -
Whole-record comparison is not supported. Pascal deliberately requires you to compare individual fields, forcing you to define what "equality" means for your data.
-
The
WITHstatement provides a shortcut for accessing record fields without repeating the variable name. It is convenient for short blocks but dangerous for long ones due to scope ambiguity. Use it sparingly, and prefer explicit dot notation when clarity matters. -
Arrays of records combine the collection power of arrays with the structuring power of records. This is the standard way to represent tables of data in Pascal — each element is a complete record, and operations like sorting automatically keep all fields together.
-
Nested records model hierarchical data by placing records inside records. Access nested fields with chained dot notation (
Emp.HomeAddress.City). Inner types likeTAddressandTDateRecare reusable across multiple outer types. -
Variant records use a
casetag field to define records whose fields change based on a discriminant value. The fixed part is shared by all variants; the variant part differs. The compiler allocates enough memory for the largest variant. Always set the tag before variant fields, and always check the tag before reading variant fields. -
Records as parameters should use
constfor read-only access (efficient, safe, self-documenting),varfor modification, and plain value parameters only when a local copy is specifically needed. Functions can return records, solving the multiple-return-value problem. -
Good record design follows the principles of one type per concept, appropriate field types (especially enumerations for fixed categories), clear and unambiguous field names, count fields for partially-used arrays, and anticipating how the data will be used in practice.
Records lay the groundwork for everything that follows in Part II. In Chapter 12, we will learn how to save records to files, enabling our programs to work with persistent data that survives between program runs. In later chapters, we will combine records with pointers to build linked lists, trees, and other dynamic data structures where records form the nodes of larger structures. And when we reach object-oriented programming, we will see that classes are essentially records that also carry procedures — an evolution, not a replacement.
Niklaus Wirth was right: algorithms plus data structures really do equal programs. With records in your toolkit, you now have the data structure side of that equation working powerfully in your favor.
What Comes Next
Chapter 12: File I/O — Persistence and Data Processing — We will learn to read and write both text files and typed files of records, giving PennyWise (and all our programs) the ability to save and load data. The combination of records and files is where Pascal programs start to feel like real applications — programs that remember things between sessions, process large datasets, and interact with the file system.