Topaz offers several data forms because “these values have the same fields” and “these values represent the same domain concept” are different promises. Start with the distinction your program needs, then choose the smallest form that preserves it.
Run one example
Save this as records-nominal-data.tpz:
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}")topaz check records-nominal-data.tpz
topaz run records-nominal-data.tpz
It prints:
7:Ada:active
Ada:review
36:37
number 7:readyChoose the data form
| Form | Use it when | Construction and observation |
|---|---|---|
| Structural record | A local value only needs a known set of fields | { name: "Ada", age: 36 }, then value.field |
Nominal record | The domain name and declaration identity matter | User { ... }; fields, defaults, named update, and record patterns |
enum | Exactly one member of a closed set is present | Status.Active or Status.Paused(reason); select with match |
Union A | B | An existing value may have one of several types | Narrow it with literal, constructor, or type patterns |
newtype | One base value needs a distinct domain identity | UserId(7), then unwrap one layer with .value() |
User is a nominal record. Omitting status runs its default once, so before is active. User { ...before, status: ... } creates a fresh outer User, retains the other fields, and does not mutate before or rerun defaults.
Status is a closed enum. Construction uses the namespace (Status.Paused), while a match arm uses the unqualified variant (case Paused(reason)). The checker can therefore require that statusLabel handles every variant.
DisplayValue is a union alias, not a new runtime wrapper. The type patterns in display narrow the existing int or string and bind it. By contrast, UserId is a new nominal wrapper even though its base is int.
preview is structural: its identity is its compatible field shape. Its update uses preview{ age: 37 }. This is a different operation from the nominal update used for User.
Construct, update, and match
Nominal record fields are checked by name and type. Explicit fields evaluate from left to right; missing defaults run afterward in declaration order. A nominal update allows one leading ...source, then replacements. It copies only the outer record, so mutable values stored in fields remain shared.
A nominal record pattern such as case User { name } => first checks that the value came from the User declaration, then reads the selected fields. Omitted fields are ignored, but the pattern must name at least one field. A structural record with the same fields is not a User.
Common mistake
JavaScript-style { ...value } is not a Topaz structural record update. Use value{ field: replacement } for a structural record and Name { ...value, field: replacement } for a nominal record.
Do not place Rust-style receiver syntax inside a record. There is no implicit this, &self, or &mut self. The bounded Topaz receiver form, when needed, is a separate module-top-level impl Name block with bare by-value self.
Exact boundaries
- Record, enum, and newtype declarations are module-top-level nominal declarations.
- Generic nominal parameters are invariant and need an expected type or another accepted inference context at construction.
- Nominal equality, ordering, and collection keys preserve declaration identity as well as compatible contents.
- Enum matches use bare variant names and are checked for complete coverage; guarded arms do not establish coverage.
- Nominal pattern heads are bare names, not namespace-qualified names, and empty nominal patterns are invalid.
- A
newtypeconstructor takes exactly one base value, and.value()unwraps exactly one layer.
Reflection, inheritance, row polymorphism, open nominal patterns, qualified construction, dynamic protocol dispatch, and Rust ownership-style receivers are not part of the current language.
Start with the course’s Data and Control example. Use Types for aliases and unions, and Patterns & Control Flow for exhaustive matching and bindings.