Type-Safe Delphi Collections with Generics
Replace unsafe casts in Delphi collections with compile-time generics. Learn how generic lists and custom stacks catch type errors at build time, plus trade-offs in binary size and compile time.
18 Aug 2026, 02:31 UTC

The cast-and-hope problem
Many older Delphi units rely on TList or TObjectList from the Classes unit. These containers store Pointer values, so every read requires a cast:
var
List: TList;
Value: Integer;
begin
List := TList.Create;
try
List.Add(Pointer(42));
Value := Integer(List[0]); // cast: easy to get wrong
finally
List.Free;
end;
end;
If another developer stores a string or an object in the same list, the cast compiles cleanly but fails at runtime. The error often appears far from the actual mistake, which makes debugging expensive in large codebases.
What generics change in Delphi
Delphi has supported generics since Delphi 2009. A generic collection is declared with a type parameter, written between angle brackets. The most common implementation lives in the System.Generics.Collections unit.
Instead of TList, you use TList<T>. The compiler tracks what T stands for in each declaration. TList<Integer> accepts only integers; TList<String> accepts only strings. If you try to add the wrong type, compilation stops before the application ever runs.
Generics are not a new runtime virtual machine. They are a compile-time code-generation mechanism: Delphi builds a specialized version of the collection for each type you actually use.
Worked example: a type-safe stack
You can use the RTL's TStack<T>, but defining a small generic type shows how the mechanism works. The example below uses TArray<T>, a built-in dynamic array whose element type matches the stack's type parameter.
unit GenericStack;
interface
uses
System.SysUtils;
type
TStack<T> = record
private
FItems: TArray<T>;
FCount: Integer;
public
procedure Push(const Value: T);
function Pop: T;
property Count: Integer read FCount;
end;
implementation
procedure TStack<T>.Push(const Value: T);
begin
if FCount = Length(FItems) then
SetLength(FItems, FCount * 2 + 1);
FItems[FCount] := Value;
Inc(FCount);
end;
function TStack<T>.Pop: T;
begin
if FCount = 0 then
raise Exception.Create('Stack is empty');
Dec(FCount);
Result := FItems[FCount];
end;
end.
Now the calling code can declare two independent stacks without a single cast:
var
IntStack: TStack<Integer>;
StrStack: TStack<String>;
begin
IntStack.Push(42);
StrStack.Push('hello');
// IntStack.Push('world'); // Does not compile: type mismatch
Writeln(IntStack.Pop);
Writeln(StrStack.Pop);
end;
Because TStack<T> is bound to Integer in one variable and String in another, the compiler rejects the mismatched push. You get the error at build time, not inside a try block in production.
Trade-offs and how to verify
Generics are not free. Each distinct type instantiation can add code to the binary, so a project that constructs TStack<Integer>, TStack<String>, and TStack<TMyRecord> carries three specialized implementations. Compile time can also grow as generic constraints become more complex. Keep signatures simple and avoid nesting generic types unnecessarily.
To verify a migration, create a small console project in Delphi 2009 or later, add the generic unit, and compile with Ctrl+F9 in the IDE. No special permissions are required beyond a normal user account. Expect zero compiler errors for correct type usage and a compile error if you deliberately add the wrong type. Run the application and exercise the push and pop paths; the absence of cast exceptions confirms the type contract is enforced.
To gauge binary impact, build the same project twice—once with an untyped TList and once with TList<Integer>—and compare the executable sizes. Treat the difference as approximate because the linker and runtime packages also affect the result. If binary size is critical, profile the actual release build before converting every collection in a large codebase.
Actionable closing
Start with one high-risk collection, replace it with TList<T> or TStack<T>, and compile. Let the compiler find the mismatched casts you did not know existed. Expand gradually, keep generic constraints simple, and check executable size on your target platform before committing to a full migration.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.