Data & Control

Records & Nominal Data

Choose data types, keep separately declared records distinct, and update fields without changing the original record.

Choose a data form for your program and learn when values with the same fields have different types. The example creates a user, changes their status, and keeps the original value.

Install Topaz using First Program. For a first exercise with records, start with Data and Control.

Create and update a user

Save this code as records-nominal-data.tpz in your working folder.

TOPAZ
type DisplayValue = int | string

newtype UserId = int

enum Status {
    Active,
    Paused(string),
}

record User {
    id: UserId,
    name: string,
    status: Status = Status.Active,
}

function statusLabel(status: Status) -> string {
    match status {
        case Active => "active"
        case Paused(reason) => reason
    }
}

function display(value: DisplayValue) -> string {
    match value {
        case number: int => "number {number}"
        case text: string => text
    }
}

let before: User = User { id: UserId(7), name: "Ada" }
let after: User = User { ...before, status: Status.Paused("review") }
let preview = { name: "Ada", age: 36 }
let older = preview{ age: 37 }
let ready = display("ready")

print("{before.id.value()}:{before.name}:{statusLabel(before.status)}")
print("{after.name}:{statusLabel(after.status)}")
print("{preview.age}:{older.age}")
print("{display(7)}:{ready}")

In that folder, check the file and run it.

BASH
topaz check records-nominal-data.tpz
topaz run records-nominal-data.tpz

The check succeeds with exit code 0. The program prints these results.

Output
7:Ada:active
Ada:review
36:37
number 7:ready

Read the results

before has the default status Active. after keeps the same ID and name but changes the status to Paused("review"). The update leaves before unchanged and does not run defaults again.

preview holds two fields without a named record declaration. Updating its age creates older, so the third line shows 36:37.

DisplayValue is a union alias. It accepts an int or a string without wrapping the value. The type patterns in display select one type and give the value a name.

Distinguish fields from declarations

A structural record is compatible according to its field names and types. For example, preview has the structure { name: string, age: int }.

A nominal record is distinguished by its declaration. User and a separately declared Customer remain different types even if their fields match. A structural record with those same fields also cannot stand in for a User.

newtype UserId = int gives an integer a separate type. Construct it with UserId(7) and read the underlying integer with .value(). Each call unwraps one layer.

NeedChooseExample
A value described by its fieldsStructural recordpreview
A distinct type with named fieldsNominal recordUser
One of a declared set of variantsenumStatus.Active or Status.Paused(reason)
A value of one of several existing typesUnion A | BDisplayValue
A separate type around one base valuenewtypeUserId

An enum has a fixed set of variants declared in its body. Create a variant with Status.Paused("review"), then match it with case Paused(reason). The checker requires enum matches to handle every variant. A guard is an extra condition on an arm. Guarded arms do not establish coverage.

Construct and update fields

Topaz checks field names and types. Explicit fields evaluate from left to right. Defaults for omitted fields then run once in declaration order.

For a nominal update, put one ...source first, followed by replacement fields. The source must come from the same record declaration with the same generic type arguments. Topaz evaluates it before the replacements. Replacements then evaluate from left to right and override the corresponding fields.

The update copies the outer record. Mutable field values such as arrays remain shared between the original and the result.

When an external program supplies a record, runtime checks also require exactly the declared fields. These checks happen before replacement expressions run.

Invalid input recordRuntime fault
Missing or duplicate fieldGUARD_ARITY (TPZ5004)
Unknown fieldGUARD_NO_FIELD (TPZ5006)

Match and correct a record

case User { name } => checks the declaration and reads name. Unmentioned fields are ignored. Name at least one field, and use a bare record name without a namespace prefix.

To update the example, use preview{ age: 37 } for the structural record and User { ...before, status: Status.Paused("review") } for the named record. JavaScript-style { ...preview } is invalid in Topaz.

Put methods in a separate module-level impl User block. Its self is immutable and passed by value. Record bodies do not provide implicit this, &self, or &mut self.

Other data rules

  • Declare records, enums, and newtypes at module level.
  • Generic nominal types are invariant, so changing a type argument produces a different type. Construction needs a known expected type, as explained in Types.
  • Equality, ordering, and use as collection keys account for the declaration as well as contents. The contained types must support the operation.
  • Reflection, inheritance, and record types with an unspecified set of extra fields are not supported. Nominal patterns must use declared fields.
  • Namespace-qualified record or newtype construction, dynamic protocol dispatch, and Rust ownership-style receivers are not supported.

Continue to Patterns & Control Flow to choose branches and extract values.