Records

A record is a group of values where each value has a name. For example:

person = { name: "Sam", age: 32 }

Here, person is a record with two fields: name (whose value is the string "Sam") and age (whose value is the number 32).

Records are a good fit when you know in advance exactly which values you'll have, and each one means something different. If you don't know the names until runtime, you want a dictionary instead.

Fields

Record Literals

A record literal is a list of fields between { and }, separated by commas. Each field is a lowercase name, a :, and an expression:

book = {
    title: "The Left Hand of Darkness",
    pages: 304,
    available: True,
}

Fields can hold any expression, including other records:

user = {
    name: "Kim",
    address: { city: "Oslo", postal_code: "0150" },
}

The trailing comma is optional, but it affects formatting: if there's a trailing comma, roc fmt puts each field on its own line. If there isn't one, it puts the whole record on one line.

If you already have a name in scope that matches the field name, you can write just the name. { name, age } is syntax sugar for { name: name, age: age }:

make_person = |name, age| { name, age }

There's one exception. { name } is a block expression that evaluates to name, not a record. To make a single-field record this way, add a trailing comma: { name, }.

Note that this exception exists because { x } comes up all the time as a block, for example in else { x }, whereas single-field records written with the shorthand are rare. Making the common case the convenient one seemed like the better tradeoff.

Accessing Fields

You can access a field with . followed by the field's name:

city = user.address.city

If the record doesn't have that field, you get an error at compile time. There's no such thing as a runtime "field not found" error, because the compiler always knows exactly which fields a record has.

You can also destructure a record to name several of its fields at once:

full_name = |person| {
    { first, last } = person
    "${first} ${last}"
}

A record pattern like { first, last } only matches records with exactly those fields. To match a record that might have other fields too, put .. at the end:

first_name = |person| {
    { first, .. } = person
    first
}

Record Types

A record type looks like a record literal, except with types instead of values, and : with spaces around it:

Person : { name : Str, age : U64 }

alice : Person
alice = { name: "Alice", age: 30 }

Here, Person is a type alias. It's just another name for { name : Str, age : U64 }, so any record with those fields (and those field types) is a Person.

The order of fields doesn't matter. These are the same type:

PersonA : { name : Str, age : U64 }
PersonB : { age : U64, name : Str }

Record Equality

Two records are equal (according to ==) if all of their fields are equal. Field order doesn't matter here either, so { a: 1, b: "x" } == { b: "x", a: 1 } is True.

Nominal records are different. They don't get == automatically; you either define an is_eq method yourself or ask the compiler to derive one.

Updating Records

Records are values, so you can't change one. What you can do is make a new record that's the same as an old one, except for some fields. The syntax for this is .. followed by the original record:

have_birthday = |person| {
    ..person,
    age: person.age + 1,
}

This returns a new record with all the fields of person, except with age replaced. The original person is unaffected. (Behind the scenes, the compiler can often reuse the original's memory; see Performance.)

Record updates can only replace fields that already exist, and the new value has to have the same type as the old one. Both of these give errors at compile time:

renamed = { ..person, nickname: "Sam" } # ERROR! person has no nickname field
aged = { ..person, age: "old" }         # ERROR! age is a number, not a Str

If you want a record with different fields, write it out:

with_display_name = |person| {
    name: person.name,
    age: person.age,
    display_name: "${person.name} (${person.age.to_str()})",
}

This restriction means that when you see { ..record, field: value }, you always know the result has the same type as record.

Compared to Dictionaries

Records and dictionaries both associate names (or keys) with values, but they're for different situations:

  • A record's fields are decided at compile time. A dictionary can gain and lose entries at runtime.
  • A record's fields can each have a different type. All the values in a dictionary have the same type.
  • A record's field names are always lowercase names written in the source code. A dictionary's keys are values, and they can be any type that supports hashing.
  • Accessing a record's field always succeeds, because the compiler has already checked that the field is there. Looking up a key in a dictionary returns a Try, because the key might not be there.
  • Accessing a record's field is just reading memory at a known location. Looking up a key in a dictionary means hashing the key and comparing it to the keys the dictionary has.

So if you know in advance exactly which fields you'll have, and each one means something different, use a record. If the keys are only known at runtime, use a dictionary.

Structural Records

Records are structural by default, which means:

  • You don't need to declare a record type before using it. { x: 0, y: 0 } works on its own.
  • Two record types are the same if they have the same field names and field types.

So a function that takes { x : F64, y : F64 } accepts any record with exactly those two fields, including a record literal written right there in the call:

distance_squared : { x : F64, y : F64 } -> F64
distance_squared = |point| point.x * point.x + point.y * point.y

answer = distance_squared({ x: 3, y: 4 })

Open Record Types

The record type { name : Str } means "a record with a name field, and no other fields." If you want to say "a record with a name field, and possibly others," put .. at the end:

get_name : { name : Str, .. } -> Str
get_name = |record| record.name

name = get_name({ name: "Ari", age: 41 }) # This is fine, even though it has an age field.

{ name : Str } is a closed record type, and { name : Str, .. } is an open record type.

If you need to refer to "the other fields" elsewhere in the type, give them a name after the ..:

set_name : { name : Str, ..others }, Str -> { name : Str, ..others }
set_name = |record, name| { ..record, name }

This says that whatever other fields came in also go out. (A plain .. is the same thing, except there's no name to refer to.)

Note that open record types describe what a function accepts. They don't make records themselves flexible at runtime. Every record that actually gets created still has a specific set of fields known at compile time, and the compiler makes a separate copy of get_name for each of those sets of fields.

Optional Fields

An optional field is a field that might not be there at runtime. You write it with ?: instead of : in the record type:

Attributes : { count : U64, label ?: Str }

labeled : Attributes
labeled = { count: 3, label: "new" }

unlabeled : Attributes
unlabeled = { count: 3 } # This is fine, because label is optional.

Since the field might be missing, you can't access it with . like a normal field. Instead, you use .?, which gives you a Try:

read_label : Attributes -> Try(Str, [MissingField])
read_label = |attributes| attributes.?label

If the field is there, you get Ok with its value. If it isn't, you get Err(MissingField). The ?? operator is a convenient way to provide a value for the missing case:

display_label = |attributes| attributes.?label ?? "untitled"

You can keep accessing fields after a .?. If any optional field along the way is missing, the whole thing is Err(MissingField):

city : { address ?: { city : Str } } -> Try(Str, [MissingField])
city = |person| person.?address.city

Optional fields are for when it matters at runtime whether the field was provided, such as when a field might or might not appear in some JSON you're parsing. If you just want a field to have a default value when someone doesn't provide it, use a defaulted field instead.

Nominal Records

A nominal record type has a name, and no other type is considered the same as it, even a record type with exactly the same fields. You declare one with :=:

Point := { x : F64, y : F64 }

To create a Point, you can write the type's name before the record:

origin = Point.{ x: 0, y: 0 }

If the compiler already knows a Point is expected (for example, because of a type annotation), you can leave off the name:

unit_x : Point
unit_x = { x: 1, y: 0 }

Since a nominal record has a name, it can also have methods:

Point := { x : F64, y : F64 }.{
    origin : Point
    origin = Point.{ x: 0, y: 0 }

    translate : Point, F64, F64 -> Point
    translate = |point, dx, dy| {
        ..point,
        x: point.x + dx,
        y: point.y + dy,
    }
}

Code outside the declaration can use these as Point.origin and point.translate(dx, dy). There's more about nominal types in general on the types page.

Opaque Nominal Records

If you declare a nominal record with :: instead of :=, it's opaque. Outside the module where it's declared, nobody can see its fields. That means they can't access them, create the record with a record literal, or destructure it. All they can do is use the functions you expose:

Account :: { balance : U64 }.{
    new : U64 -> Account
    new = |balance| Account.{ balance }

    balance : Account -> U64
    balance = |Account.{ balance }| balance
}

This is useful when you want to enforce rules about what's in a record. For example, if an Account should never be created with a balance above some limit, new is the only way to create one, so new is the only place that needs to check the limit. (The types page covers opaque types in general.)

Defaulted Fields

A defaulted field is a field in a nominal record that you can leave out when creating the record, in which case it gets a default value. You write the default after ??:

RequestOptions := {
    retries : U8 ?? 3,
    timeout_ms : U64 ?? 5000,
}

Now you can leave out either field (or both):

standard = RequestOptions.{}                     # retries is 3, timeout_ms is 5000
patient = RequestOptions.{ timeout_ms: 30000 }   # retries is 3, timeout_ms is 30000

Once the record has been created, a defaulted field is just a normal field. It's always there, so you access it with . like any other field:

attempts = standard.retries

The default can be any expression, as long as it doesn't call any effectful functions:

CacheOptions := {
    capacity : U64 ?? {
        kibibytes = 1024
        8 * kibibytes
    },
}

The default gets evaluated each time a record is created without that field.

Defaults can't depend on each other in a cycle, and they can't affect what the nominal type's type parameters are (if it has any).

Note that defaulted fields and optional fields answer different questions. A defaulted field asks "what should this be if nobody says?" and the answer is always a value. An optional field asks "did anybody say?" and the answer might be no, which is why accessing it gives you a Try.

The Empty Record ({})

{} is a record with no fields. It's also the name of its own type:

empty : {}
empty = {}

There's exactly one value of type {}, which is {}. That makes it useful when you need to provide a value, but there's no information to provide.

Note that {} is a closed record type with no fields, whereas { .. } is an open record type that accepts any record at all.

If you want a module that's just a namespace for some functions, and you never want anyone to create a value of the module's type, use the empty tag union [] as the backing type instead of {}. There are no values of type [], so there can be no values of your type either:

Math :: [].{
    double : U64 -> U64
    double = |n| n * 2
}

answer = Math.double(21)

Performance

Memory Layout

A record's fields are stored next to each other in memory, much like a struct in C. The record doesn't get its own heap allocation and doesn't have a reference count. It takes up exactly as much space as its fields do, plus any padding needed for alignment.

None of the record's field names exist at runtime. When you write user.name, the compiler already knows exactly how many bytes from the start of the record name is, so accessing a field is as fast as reading memory at a known offset. (This is one of the big performance differences between records and dictionaries. Looking something up in a dictionary means hashing the key and comparing it against the keys stored in the dictionary.)

Fields that are themselves heap-allocated, like strings and lists, work the same way they do anywhere else. For example, on a 64-bit target, a Str field takes up 24 bytes in the record. If the string is 23 bytes or shorter, its contents fit right there in those 24 bytes; otherwise, its contents live in a separate heap allocation. The record holds the string, but the string manages its own memory.

Here are the sizes of some common field types:

Type32-bit size and alignment64-bit size and alignment
U81 byte, aligned to 11 byte, aligned to 1
U324 bytes, aligned to 44 bytes, aligned to 4
U648 bytes, aligned to 88 bytes, aligned to 8
Str12 bytes, aligned to 424 bytes, aligned to 8
List(a)12 bytes, aligned to 424 bytes, aligned to 8
Box(a)4 bytes, aligned to 48 bytes, aligned to 8

Field Order and Padding

The order you write fields in has no effect on how they're laid out in memory. The compiler sorts them by alignment, from most strictly aligned to least, and sorts fields with the same alignment alphabetically by name. This puts as little padding between fields as possible.

For example, { flag : Bool, count : U64, id : U32 } is laid out as count, then id, then flag. That's 16 bytes total (8 + 4 + 1, rounded up to a multiple of 8). If the fields were stored in the order they were written, it would be 24 bytes, because count would need 7 bytes of padding before it.

This means you never need to reorder a record's fields to make it smaller. The compiler has already done it.

Optional Fields Take Up More Space

At runtime, an optional field is stored like a tag union with two tags: one for when the field is present (holding its value) and one for when it's missing. That takes the space of the value, plus one byte to say which tag it is, plus padding to keep the next value aligned.

For example, on a 64-bit target, label : Str takes 24 bytes, but label ?: Str takes

  1. If you're storing a lot of records in a list and you don't

actually need to know whether a field was provided, a defaulted field takes no extra space.

Copying and Updating

Since records are stored inline, passing a record around means its bytes get copied around. For small records, that's cheap. It's also cheap for records whose fields are strings or lists, because only the small fixed-size part of each string or list gets copied, not its contents.

When a record contains heap-allocated fields and you use the record somewhere it's still needed later, those fields' reference counts get incremented. This works the same way as using a string or list directly.

Updating a record with { ..record, field: value } creates a new record. If the original record isn't used again afterward, the fields that didn't change (including any strings and lists in them) get moved into the new record without touching their reference counts.

Records at the Host Boundary

Most of the time, the compiler's field order is exactly what you want. The exception is when a platform needs a record's memory to match a struct in some other language, which expects fields in the order they were written.

For this, a nominal record can include a field named _. This makes the compiler lay out the record's fields in the order you wrote them, with padding inserted the way C would insert it:

Header := {
    tag : U8,
    _ : {},
    value : U32,
}

The _ : {} field takes up no space; it's only there to say "use the order I wrote." So tag is at offset 0, followed by three bytes of padding, and then value is at offset 4. (Without the _ field, value would come first, because it has the stricter alignment.)

A _ field with a type other than {} reserves that many bytes of padding. These bytes don't hold a value and can't be accessed:

Padded := {
    tag : U32,
    _ : U32,
    value : U32,
}

Here, tag is at offset 0, four reserved bytes are at offset 4, and value is at offset 8. Note that a reserved field is always aligned to 1 byte, regardless of its type. You can also give reserved fields names that start with _, like _reserved.

Only nominal records can have _ fields.

Note that platform authors shouldn't compute these offsets by hand. roc glue generates host code using the exact layout the compiler chose for each target, on both 32-bit and 64-bit targets.