Lightweight JSON Serialization in Delphi with RTTI Custom Attributes
Use Delphi's RTTI and custom attributes to declaratively serialize objects to JSON, eliminating manual mapping boilerplate while keeping the serialization logic in sync with class definitions.
08 Jun 2026, 20:49 UTC

The Problem: Manual JSON Mapping Doesn't Scale
Every Delphi developer who has written a TJSONObject builder knows the drill: iterate fields, check types, handle nested objects, and remember to update the mapping whenever a property is added or renamed. The code grows, the bug surface expands, and the class definition drifts away from its serialization logic.
Delphi's Run‑Time Type Information (RTTI) combined with custom attributes offers a declarative alternative. You annotate properties once, and a small generic routine reads the attributes at runtime to produce or consume JSON. The mapping stays in sync with the class because it is the class.
Declarative Serialization with Attributes
Custom attributes are plain classes descending from TCustomAttribute. When the compiler sees an attribute attached to a property, it stores the attribute instance in the property's RTTI metadata. A serialization routine can then enumerate properties, inspect their attributes, and decide whether to include, rename, or skip each field.
To make attributes visible you must enable extended RTTI for properties. Add this directive at the top of the unit that declares your serializable classes:
{$RTTI EXPLICIT METHODS([]) PROPERTIES ALL}
Without it the property list is empty and the attribute lookup silently returns nothing.
Worked Example: A Person Record
Below is a minimal, self‑contained example that compiles in Delphi 10.4 Sydney or later. It shows attribute definitions, a sample class, and the two core functions ToJSON and FromJSON.
unit JsonSer;
interface
uses
System.Rtti, System.JSON, System.SysUtils, System.Generics.Collections;
// Attribute declarations
JSONNameAttribute = class(TCustomAttribute)
private
FName: string;
public
constructor Create(const AName: string);
property Name: string read FName;
end;
JSONIgnoreAttribute = class(TCustomAttribute)
end;
// Sample class
TPerson = class
private
FFirstName: string;
FLastName: string;
FAge: Integer;
FInternalId: TGUID; // not for export
public
[JSONName('full_name')]
property FullName: string read FFirstName write FFirstName;
[JSONName('surname')]
property LastName: string read FLastName write FLastName;
property Age: Integer read FAge write FAge;
[JSONIgnore]
property InternalId: TGUID read FInternalId write FInternalId;
end;
function ToJSON(const AObject: TObject): string;
function FromJSON(const AJson: string; AObject: TObject): Boolean;
implementation
{ JSONNameAttribute }
constructor JSONNameAttribute.Create(const AName: string);
begin
FName := AName;
end;
{ Serialization }
function ToJSON(const AObject: TObject): string;
var
Ctx: TRttiContext;
Typ: TRttiType;
Prop: TRttiProperty;
Attr: TCustomAttribute;
JObj: TJSONObject;
JVal: TJSONValue;
Name: string;
begin
Ctx := TRttiContext.Create;
try
Typ := Ctx.GetType(AObject.ClassType);
JObj := TJSONObject.Create;
try
for Prop in Typ.GetProperties do
begin
// Skip write‑only or indexed properties
if not Prop.IsReadable then Continue;
// Check for [JSONIgnore]
for Attr in Prop.GetAttributes do
if Attr is JSONIgnoreAttribute then
Continue;
// Determine output name
Name := Prop.Name;
for Attr in Prop.GetAttributes do
if Attr is JSONNameAttribute then
Name := JSONNameAttribute(Attr).Name;
// Convert value to JSON
JVal := TJSONValue.FromVariant(Prop.GetValue(AObject).AsVariant);
if Assigned(JVal) then
JObj.AddPair(Name, JVal);
end;
Result := JObj.ToJSON;
finally
JObj.Free;
end;
finally
Ctx.Free;
end;
end;
{ Deserialization }
function FromJSON(const AJson: string; AObject: TObject): Boolean;
var
Ctx: TRttiContext;
Typ: TRttiType;
Prop: TRttiProperty;
Attr: TCustomAttribute;
JObj: TJSONObject;
JVal: TJSONValue;
Name: string;
Ignore: Boolean;
begin
Result := False;
JObj := TJSONObject.ParseJSONValue(AJson) as TJSONObject;
if not Assigned(JObj) then Exit;
try
Ctx := TRttiContext.Create;
try
Typ := Ctx.GetType(AObject.ClassType);
for Prop in Typ.GetProperties do
begin
if not Prop.IsWritable then Continue;
Ignore := False;
Name := Prop.Name;
for Attr in Prop.GetAttributes do
begin
if Attr is JSONIgnoreAttribute then
Ignore := True
else if Attr is JSONNameAttribute then
Name := JSONNameAttribute(Attr).Name;
end;
if Ignore then Continue;
if JObj.TryGetValue(Name, JVal) then
begin
// Simple variant assignment works for string, integer, double, boolean
Prop.SetValue(AObject, JVal.Value);
end;
end;
Result := True;
finally
Ctx.Free;
end;
finally
JObj.Free;
end;
end;
end.
Usage in a button click or test routine:
var
Person: TPerson;
JsonStr: string;
begin
Person := TPerson.Create;
try
Person.FullName := 'Jane';
Person.LastName := 'Doe';
Person.Age := 34;
Person.InternalId := TGUID.NewGuid;
JsonStr := ToJSON(Person);
// JsonStr => {"full_name":"Jane","surname":"Doe","Age":34}
ShowMessage(JsonStr);
// Round‑trip
Person.Free;
Person := TPerson.Create;
FromJSON(JsonStr, Person);
// Person.FullName = 'Jane', Person.LastName = 'Doe', Person.Age = 34
// Person.InternalId remains empty
finally
Person.Free;
end;
end;
Trade‑offs and Limitations
- Binary size: Enabling
{$RTTI PROPERTIES ALL}adds a RTTI section to the executable. On a typical VCL forms application this can increase the binary by 100–300 KB per unit that declares serializable classes. - Runtime overhead: Each serialization call walks the RTTI property list and creates attribute instances. In tight loops (e.g., serializing thousands of records per second) the reflection cost becomes measurable. Cache the
TRttiTypeand attribute lookups if you need high throughput. - Mobile targets: iOS and Android builds often strip RTTI to reduce size. If you disable RTTI via project options or
{$RTTI EXPLICIT PROPERTIES []}, attribute‑based serialization will silently omit all properties. - Attribute visibility: The attribute classes must be compiled into the same package or unit that uses them. If you move
JSONNameAttributeto a runtime package but forget to link that package,GetAttributesreturns an empty array and your custom names are ignored.
Verifying It Works
- Create a new VCL or FireMonkey project in Delphi 10.4+.
- Add the unit above, include
{$RTTI EXPLICIT METHODS([]) PROPERTIES ALL}at the top. - Build and run. The
ShowMessageshould display JSON withfull_nameandsurnamebut noInternalId. - Optional: enable the linker map file (Project → Options → Linking → Map file = Detailed) and search for
.rttisections. Their presence confirms RTTI is emitted.
If the JSON output is empty or missing renamed fields, double‑check that the attribute classes are in the same unit (or a linked package) and that the {$RTTI} directive is active for the unit containing TPerson.
Closing Thought
RTTI‑driven serialization trades a modest increase in binary size and a small runtime cost for a dramatic reduction in boilerplate and a single source of truth for your data contracts. For most line‑of‑business VCL/FireMonkey apps the trade‑off is favorable—just profile before you ship a high‑frequency serialization path.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.