In Dart, when are two record types the same type, and how do positional field names, named field names and field order affect it?
answer
- records are structurally typed
- shape: positional count plus named names
- positional names are documentation
- named field order is irrelevant
- pointwise subtyping of field types
basics
~20 sRecord types are structural: the same shape and field types make the same type. Positional field names are documentation only, named field names are part of the type, and the order of named fields does not matter.
solid answer
~40 sA record type has no declaration; it is defined by its **shape** — the number of positional fields and the set of named-field names — plus each field's type. So `(int a, int b)` and `(int x, int y)` are the same type, because positional names are purely documentation, while `({int a, int b})` and `({int x, int y})` are different types, so assigning one to the other is a compile error. Named fields are a set, so `(a: 1, b: 2)` and `(b: 2, a: 1)` have the same type and are equal. Subtyping is pointwise within one shape: `(int, String)` is a subtype of `(num, Object)`, while records of different shapes are unrelated. Two libraries that never import each other can therefore exchange `({double lat, double lng})` values freely.
code
dart · 16 linesvoid main() {
(int a, int b) ab = (1, 2);
(int x, int y) xy = (3, 4);
ab = xy; // OK: positional names are documentation
({int a, int b}) namedAb = (a: 1, b: 2);
({int b, int a}) reordered = (b: 2, a: 1);
print(namedAb == reordered); // true: same shape, same values
({int x, int y}) namedXy = (x: 3, y: 4);
// namedAb = namedXy; // compile error: different named fields
(num, Object) wide = (42, 'a'); // pointwise subtype
print(wide.$1.runtimeType); // int
print(ab.$1 + namedXy.x); // 6
}go deeper
Remember that record types are written by their fields and that names on positional fields do not matter to the type.
Explain shape: positional count plus the set of named-field names, with pointwise subtyping within a shape and no relation across shapes.
Use structural typing deliberately: prefer named fields where two same-typed values could be swapped, and know that renaming a named field is a breaking change the compiler will surface.
Decide where structural record types are a coupling benefit between packages and where nominal types should guard against same-shaped values being mixed up.
## Structural, not nominal Most Dart types are **nominal**: `class Celsius` and `class Fahrenheit` are different types even if both wrap one `double`, because they have different declarations. Record types have **no declaration at all**. A record type is identified by its **shape** and its field types: - the **number of positional fields**; - the **set of names** of its named fields; - the **type** of every field. Two record types with the same shape and the same field types are the **same type**, wherever they were written. The Dart documentation's example is two unrelated libraries that both produce records with the same fields: the type system treats them as one type even though the libraries know nothing about each other. ## What counts and what does not | Change between two record types | Same type? | |---|---| | `(int a, int b)` vs `(int x, int y)` | **Yes** — names on positional fields are documentation only | | `({int a, int b})` vs `({int x, int y})` | **No** — named-field names are part of the shape | | `({int a, int b})` vs `({int b, int a})` | **Yes** — named fields form a set; order is irrelevant | | `(int, String)` vs `(String, int)` | **No** — positional order matters | | `(int, int)` vs `(int, int, int)` | **No** — different number of positional fields | | `(int, {int b})` vs `(int, int)` | **No** — one named versus one positional field is a different shape | The positional-name rule mirrors function types, where `typedef F = int Function(int value);` gives `value` no meaning to the type checker. ## Subtyping between records Within a single shape, record types are **covariant field by field**: `(int, String, {bool isValid})` is a subtype of `(num, String, {Object isValid})`, because each field type is a subtype of the corresponding one. That is why this compiles: ```dart (num, Object) pair = (42, 'a'); var first = pair.$1; // static type num, runtime type int ``` Records of **different shapes are unrelated**; neither is a subtype of the other. Every record type is a subtype of the class **`Record`** from `dart:core`, and through it of `Object`. `Record` itself is an `abstract final class`: no object's runtime type is plain `Record`, and your own classes cannot extend or implement it. ## Getter names follow from the shape Because a record type is its shape, the getters are fixed by it too: 1. Named fields get getters with their own names: `r.min`. 2. Positional fields get `$1`, `$2`, … counted over positional fields only, skipping named ones: in `('first', a: 2, b: true, 'last')`, `$2` is `'last'`. 3. Some names are not allowed for named fields: the `Object` members `hashCode`, `runtimeType`, `toString` and `noSuchMethod`; any name starting with `_`, since record fields cannot be library-private; and a `$n` name that collides with a positional getter of the same record, so `(0, $1: 0)` is invalid. ## The runtime type follows the same rule At run time a record's type is also defined by its shape and by the runtime types of its field values. The `Type` object you get from `runtimeType` equals another record's `Type` only when both have the same shape and the same field types. So `(1, 'a').runtimeType == (2, 'b').runtimeType` is `true`, while `(1, 'a').runtimeType == ('a', 1).runtimeType` is `false`. Type tests work the same way: `(1, 'a') is (num, Object)` is `true` because of pointwise subtyping. ## Why it matters in practice - **Loose coupling.** A package can return `({double lat, double lng})` and an app can accept that type without importing any shared model. - **Accidental interchangeability.** The same property means any `(double, double)` is accepted where another `(double, double)` is expected: a min/max pair and a lat/lng pair are the same type if both are positional. Named fields reduce the risk, because different names make different types. - **Refactoring.** Renaming a **named** field changes the type and breaks every user — the compiler finds them all. Renaming a **positional** field name changes nothing. - **Readability.** A `typedef` gives a long record type a short name, but it is only an alias: `typedef Range = ({double min, double max});` is the same type as the literal record type everywhere.
- Is `(int, String)` assignable to a variable of type `(num, Object)`, and the other way round?The first direction works: record subtyping is pointwise within one shape, and `int` and `String` are subtypes of `num` and `Object`. The reverse is a downcast from a non-dynamic type, so it is a compile error without an explicit `as (int, String)`, which then checks at run time.
- Why can a record not have a named field called `hashCode` or `_id`?Field names become getters on the record, so a field named after an `Object` member such as `hashCode` would clash with the record's own member, and names starting with `_` are disallowed because record fields cannot be library-private.
saying these in an interview costs you the question
- Thinks (int a, int b) and (int x, int y) are different types
- Believes named fields must appear in the declared order
- Assumes two libraries' identical record shapes are incompatible types
- Thinks (int, int) is a subtype of (int, int, int)
- Thinks a typedef creates a new, distinct record type