Records in Dart — Lightweight, Immutable, and Structural
Dart 3 introduced records — anonymous, immutable data structures with built-in equality. They fill the gap between ad-hoc Maps and full-blown classes.
What are records?
We have all been there. We need to return two values from a function. Maybe a status and a message. Maybe coordinates. Maybe a parsed result and an error flag.
Before Dart 3, we had three choices. Create a class for it — heavyweight for such a small thing. Return a Map — no type safety, stringly-typed keys. Return a List — positional access with no names, easy to mix up.
Records solve this. They are lightweight, anonymous data structures that group values together. They are immutable. They have structural equality. And they require zero boilerplate.
// Before records — awkward Map
Map<String, dynamic> getUser() {
return {'name': 'Ankit', 'age': 25};
}
var user = getUser();
print(user['name']); // no type safety
// With records — clean and typed
(String, int) getUser() {
return ('Ankit', 25);
}
var user = getUser();
print(user.$1); // 'Ankit' — typed as String
Records arrived in Dart 3.0 (May 2023). They fill the gap between throwaway tuples and proper classes — perfect for grouping values without the ceremony of defining a type.
Record syntax — positional and named fields
Records use parentheses. Inside the parentheses, we can have positional fields, named fields, or both.
Positional fields — values separated by commas. Access them with $1, $2, and so on (1-indexed).
var point = (10, 20);
print(point.$1); // 10
print(point.$2); // 20
var rgb = (255, 128, 64);
print(rgb.$3); // 64
Named fields — use a colon, just like Map literals. Access them by name.
var user = (name: 'Ankit', age: 25);
print(user.name); // 'Ankit'
print(user.age); // 25
Mixed — positional first, then named. The positional indices skip the named fields.
var person = ('Ankit', age: 25, city: 'Delhi');
print(person.$1); // 'Ankit'
print(person.age); // 25
print(person.city); // 'Delhi'
The trailing comma rule. A single-element record needs a trailing comma to distinguish it from a grouped expression.
(42) is just 42. To make it a record, write (42,).
Record types and type annotations
Every record has a type. The type describes the structure — the number of fields, their types, and for named fields, their names.
// Positional record type
(int, int) point = (10, 20);
// Named record type
({String name, int age}) user = (name: 'Ankit', age: 25);
// Mixed record type
(String, {int id}) tagged = ('item', id: 42);
// Function returning a record
(bool, String) validate(String input) {
if (input.isEmpty) return (false, 'Input is empty');
return (true, 'OK');
}
Type equivalence is structural. Two record types are the same if they have the same fields in the same positions with the same types. The variable names we use don't matter.
(int, int) point = (10, 20);
(int, int) dimensions = (100, 200);
// These have the same type
point = dimensions; // works fine
// But named fields must match
({int x, int y}) coord = (x: 1, y: 2);
({int a, int b}) size = (a: 3, b: 4);
// coord = size; // error — different field names
The structural typing means we don't have to declare record types anywhere. They emerge from the shape of the data. But it also means two records with the same shape but different semantics (like a Point and a Size) are interchangeable — be careful when that's not what we want.
Destructuring records
We can pull values out of records by position or name directly in a declaration. This is called destructuring.
var point = (10, 20);
// Destructure into separate variables
var (x, y) = point;
print(x); // 10
print(y); // 20
// With named fields
var user = (name: 'Ankit', age: 25);
var (:name, :age) = user; // shorthand for (name: name, age: age)
print(name); // 'Ankit'
print(age); // 25
Destructuring in function returns. This is where records really shine.
(int, int) divide(int a, int b) {
return (a ~/ b, a % b); // quotient and remainder
}
var (quotient, remainder) = divide(17, 5);
print('17 / 5 = $quotient remainder $remainder');
// 17 / 5 = 3 remainder 2
Destructuring in switch and if-case. Records pair beautifully with Dart 3's pattern matching.
var result = (success: true, data: 'payload');
if (result case (success: true, data: var d)) {
print('Got data: $d');
}
// Or in a switch
switch (result) {
case (success: true, data: var d):
print('Success: $d');
case (success: false, :var data):
print('Failed: $data');
}
Destructuring eliminates the awkward dance of extracting fields one at a time. Combined with multiple return values, it makes functions much more expressive.
Records vs classes vs maps
We now have three ways to bundle data: records, classes, and maps. When should we use each?
Records are best for:
- Multiple return values from a function
- Short-lived groupings (local to one function or file)
- Destructuring into separate variables
- When we don't need methods or identity
Classes are best for:
- Named types that appear across the codebase
- Behaviour (methods) attached to data
- When identity matters (two users with the same name are not the same user)
- Inheritance, interfaces, encapsulation
Maps are best for:
- Dynamic keys (user input, JSON with unknown structure)
- When the set of keys varies at runtime
- String-keyed lookups where we don't know keys ahead of time
// Record — simple grouping, no methods
var point = (x: 10, y: 20);
// Class — reusable type with methods
class Point {
final int x, y;
Point(this.x, this.y);
double distanceTo(Point other) => ...;
}
// Map — dynamic keys
var config = {'host': 'localhost', 'port': 8080};
The rule of thumb: if we're tempted to create a class with just two or three final fields, a constructor, and maybe an override of
== — reach for a record instead.
Records in practice
Let's see records solving real problems.
Multiple return values. The classic use case.
(bool isValid, String? error) validateEmail(String email) {
if (!email.contains('@')) {
return (false, 'Missing @ symbol');
}
if (email.length < 5) {
return (false, 'Too short');
}
return (true, null);
}
var (isValid, error) = validateEmail('test');
if (!isValid) print('Error: $error');
Records as Map keys. Because records have structural equality and a valid hashCode, they work as Map keys. This is something regular objects cannot do (unless we override == and hashCode ourselves).
var grid = <(int, int), String>{};
grid[(0, 0)] = 'origin';
grid[(1, 2)] = 'point A';
grid[(3, 4)] = 'point B';
print(grid[(1, 2)]); // 'point A'
// Works because (1, 2) == (1, 2) structurally
var key = (1, 2);
print(grid[key]); // 'point A'
Swapping values. Destructuring makes swapping elegant.
var a = 1;
var b = 2;
(a, b) = (b, a); // swap in one line
print(a); // 2
print(b); // 1
Parsing structured data. When a function needs to return success/failure plus data.
(int? value, String? error) parseInt(String s) {
try {
return (int.parse(s), null);
} catch (_) {
return (null, 'Not a valid integer');
}
}
var (value, error) = parseInt('42');
if (error == null) {
print('Parsed: $value');
}
Records make these patterns readable without introducing named types for every small grouping.
Performance and memory
Records are value types with structural equality. What does that mean for performance?
Equality is field-by-field. Two records are equal if all their fields are equal. Dart computes this automatically — we don't override ==.
var a = (1, 2);
var b = (1, 2);
var c = (1, 3);
print(a == b); // true — same fields
print(a == c); // false — different second field
print(identical(a, b)); // could be true or false — see below
Canonicalisation. The Dart compiler is allowed to canonicalise records with the same constant values. If we write const (1, 2) twice, we may get the same object. But for non-const records, each expression creates a fresh instance (though the VM may optimise this).
const r1 = (1, 2);
const r2 = (1, 2);
print(identical(r1, r2)); // true — const records are canonicalised
var r3 = (1, 2);
var r4 = (1, 2);
print(identical(r3, r4)); // likely false — not const
Immutability. Records are always immutable. There is no way to change a field after creation. This makes them safe to share between functions and even across isolates (when the field types are also sendable).
Memory layout. Records are compact. A two-field record of small integers uses roughly the same memory as storing two integers separately — there is no per-instance metadata overhead like a full class object would have. For hot paths returning multiple values, records avoid the allocation overhead of creating a wrapper class.
Hash codes. The
hashCode of a record combines the hash codes of its fields. This is computed on demand and makes records suitable as Map keys or Set elements without extra work.
var points = <(int, int)>{};
points.add((1, 2));
points.add((1, 2)); // duplicate — Set keeps one
print(points.length); // 1
Records bring the convenience of tuples from other languages, with full type safety and the structural equality semantics that make them usable anywhere we need value-based comparison.
Test your understanding
7 questions
Seven questions on Dart records — syntax, destructuring, equality, and when to use them.