© The Author(s), under exclusive license to APress Media, LLC, part of Springer Nature 2023
D. YangIntroducing ReScripthttps://doi.org/10.1007/978-1-4842-8888-7_4

4. Records and Objects

Danny Yang1  
(1)
Mountain View, CA, USA
 

In this chapter, we’ll learn about two more composite data types: records and objects. They are useful for representing structures with named fields, and have many similarities with objects in JavaScript. We’ll also discuss two different ways of thinking about types: structural and nominal typing.

Records

Records are another product type that can contain several values, similar to tuples. While tuples are a collection of ordered values, records are a collection of named values. Each record contains a number of fields with unique names each associated with a value, similar to how JavaScript objects have named properties.

Records have several important properties that differentiate them from JavaScript objects and other data types in ReScript:
  • Immutable by default – Unlike JavaScript objects, fields in a record are immutable by default. Updating a field will yield a new record without changing the original. However, fields may also be explicitly marked as mutable.

  • Nominal typing – Typechecking for records is based on the name of the type declaration, not what fields it has.

  • Nonextensible – We cannot arbitrarily add new fields to records, and all the fields have to be defined when the record is created.

Declaring and Creating Records

Types for records need to be declared ahead of time, and cannot be extended. For example, here is a record that can be used to model a dog:
type dog = {
 name: string,
 age: int,
 owner: string,
}
Record literals look similar to JavaScript object literals:
let myDog: dog = { name: "Ruffus", age: 2, owner: "Danny" }

Nominal Typing

Records in ReScript use nominal typing, which means that records of different named types cannot be used interchangeably, even if they have the same fields.

When multiple record types have similar sets of fields, it is often a good idea to explicitly label the type of record inputs or name bindings to remove ambiguity, for both the programmer and the typechecker.

If unlabeled, the type of the record is inferred to be the type of the closest binding. In the following example with identical dog and cat record types, if the binding for myCat were unannotated, then the record would be inferred to have type dog, since that is the closest matching type declaration:
type cat = {
 name: string,
 age: int,
 owner: string,
}
type dog = {
 name: string,
 age: int,
 owner: string,
}
let myDog = { name: "Ruffus", age: 2, owner: "Danny" }
let myCat : cat = { name: "Creamsicle", age: 13, owner: "Danny" }

Accessing Record Fields

Record field accesses use the same dot notation as JavaScript object property accesses, as seen in the following example:
let dogYears = (dog: dog) => {
 switch dog.age {
 | 0 => 0
 | 1 => 15
 | 2 => 24
 | _ => 5 * (dog.age - 2) + 24
 }
}
Js.log(dogYears(myDog))
Console output:
24
Although the implementation of the function dogYears looks like it can be called on any record that has an age field, the type annotation on the parameter means it can only be called on dog type records. Even though cat type records have the exact same fields, the function cannot be called:
Js.log(dogYears(myCat))
Compiler output:
This has type: cat
 Somewhere wanted: dog

Updating Records

By default, records are immutable. This means that updating a record does not change the original; it makes a new record with the changes applied.

To create an updated record, we can use the spread operator ... in a new record literal. In the following example, the value of the age field in the original record is not changed:
let myDog = { name: "Ruffus", age: 2, owner: "Danny" }
let myDogOlder = {...myDog, age: 3}
Js.log(myDog.age)
Js.log(myDogOlder.age)
Console output:
2
3

Mutable Fields

Record fields can explicitly be marked as mutable in their type definition, which allows them to be updated in-place after initialization. In the following example, the age field is mutable:
type mutdog = {
   name: string,
   mutable age: int,
   owner: string,
}
let myMutableDog : mutdog = { name: "Ruffus", age: 2, owner: "Danny" }
Js.log(myMutableDog.age)
myMutableDog.age = 3
Js.log(myMutableDog.age)
Console output:
2
3

Optional Fields

A record field may be marked as optional by adding ? after the field name:
type dogWithOptionalOwner = {
   name: string,
   age: int,
   owner?: string
}
When creating the record, the field may be omitted:
let dog1: dogWithOptionalOwner = {
   name: "Fido",
   age: 1
}
let dog2: dogWithOptionalOwner = {
   name: "Rover",
   age: 1,
   owner: "Danny"
}
When accessing the field at runtime, its value is an option:
let owner1 : option<string> = dog1.owner
let owner2 : option<string> = dog2.owner
owner1->Belt.Option.getWithDefault("no owner!")->Js.log
owner2->Belt.Option.getWithDefault("no owner!")->Js.log
Console output:
no owner!
Danny

Destructuring Records

Records can also be destructured in both let bindings and function declarations, allowing us to access their fields concisely.

In the following section, we’ll be using this record type and declaration:
type dog = {
 name: string,
 age: int,
 owner: string,
}
let myDog = { name: "Ruffus", age: 2, owner: "Danny" }
The simplest way of destructuring records is providing a list of field names inside curly brackets.

Destructuring

Equivalent

let {name, age, owner} = myDog

let name = myDog.name

let age = myDog.age

let owner = myDog.owner

We do not have to include every field in this list, but field names we provide must match the field names in the record.

For example, this pattern which omits a binding for the “owner” field is allowed:
let {name, age} = myDog
This pattern which tries to bind a field that does not exist in the record is not allowed:
let {name, color} = myDog
Compiler output:
The record field color can’t be found.
Similar to destructuring tuples, we can use as to bind a field to a different name. With records, we may also use a : to separate the field’s original name from the new name.

Destructuring

Equivalent

let {name as n, age as a} = myDog

let {name: n, age: a} = myDog

let n = myDog.name

let a = myDog.age

This is useful in cases when we do not want our destructured field to shadow another binding, such as in the following example:
let name = "Danny"
let {name: dogName, age} = myDog
Js.log(`${name} has a ${Js.Int.toString(age)} year old dog named ${dogName}`)
Console output:
"Danny has a 2 year old dog named Ruffus"
If we had not bound the “name” field to a different name, then the binding would have been shadowed, causing unexpected behavior:
let name = "Danny"
let {name, age} = myDog
Js.log(`${name} has a ${Js.Int.toString(age)} year old dog named ${dogName}`)
Console output:
"Ruffus has a 2 year old dog named Ruffus"
The as keyword also allows us to simultaneously destructure a record and bind it to a name. This is useful when the record we are destructuring doesn’t have a name yet (e.g., if we are destructuring a record literal or a nested record).

Destructuring

Equivalent

let {name, age, owner} as myDogOlder = {...myDog, age: 5}

let myDogOlder = { name: "Ruffus", age: 2, owner: "Danny" }

let name = myDogOlder.name

let age = myDogOlder.age

let owner = myDogOlder.owner

All of these destructuring techniques can also be used in function declarations to destructure record inputs.

Destructuring

Equivalent

let f = ({name}) => {

...

}

let f = ({name} as dog) => {

...

}

let f = ({name as n} as dog) => {

...

}

let f = param => {

let name = param.name

...

}

let f = dog => {

let name = dog.name

...

}

let f = dog => {

let n = dog.name

...

}

Pattern Matching with Records

Records can be pattern matched inside switches, allowing us to concisely check fields of record types. For these examples, we’ll continue matching on the dog record type.

Matching against literal values looks almost like writing a record literal, except we do not have to include all the fields. This example matches any dog that has age 0:
let matchRecord1 = dog => {
 switch dog {
 | {age: 0} => true
 | _ => false
 }
}
We can match against multiple literal values separated by pipes |. This example matches any dog that has age 1 or 2:
let matchRecord2 = dog => {
 switch dog {
 | {age: 1 | 2} => true
 | _ => false
 }
}
We do not have to provide a literal value to match against, and we can destructure the record into named fields or use when to match a more complex condition. This example matches any dog whose name is the same as its owner’s name:
let matchRecord3 = dog => {
 switch dog {
 | {name, owner} when name == owner => true
 | _ => false
 }
}
This example matches any dog whose name is longer than ten characters. Note that here we also bind the “name” field to n:
let matchRecord4 = dog => {
 switch dog {
 | {name: n} when Js.String.length(n) > 10 => true
 | _ => false
 }
}

The ability to pattern match on records allows us to combine them with tuples and variants to define complex data types that can be easily manipulated using pattern matching.

Records and Variants

To illustrate how to use records with variants, let’s revisit the chess example from the previous chapter.

We observed that one downside of using tuples for our chess examples is the ambiguity due to the lack of labeled names. This makes it hard to tell apart two values with the same type, like the two boolean flags we defined for our king:
| King(color, position, bool, bool)
Records help solve this problem, by providing a named mapping for the fields of each piece. Redefining our chess piece variants using records gives us something like this:
type chessPieceRecord =
 | Pawn({color: color, pos: position, hasMoved: bool})
 | Knight({color: color, pos: position})
 | Bishop({color: color, pos: position})
 | Queen({color: color, pos: position})
 | Rook({color: color, pos: position, hasMoved: bool})
 | King({color: color, pos: position, hasMoved: bool, inCheck: bool})

Notice that the record types do not need to have separate named type declarations when used in a variant type definition.

To create a value of this new type, we can call the constructor with a record literal:
let rook: chessPieceRecord = Rook({color: White, pos: (1, 1), hasMoved: false})

Although variants and tuples can already be pattern matched pretty efficiently, using records instead of tuples can make the code a lot more readable.

The color function no longer has to include a bunch of underscores for ignored tuple fields:
let color = piece => {
 switch piece {
 | Pawn({color})
 | Knight({color})
 | Bishop({color})
 | Rook({color})
 | Queen({color})
 | King({color}) => color
 }
}
The canCastle function can now be more explicit about each field that it is checking, making the code easier to read:
let canCastle = (moving: chessPieceRecord, target: chessPieceRecord) => {
 switch (moving, target) {
 | (Rook({color: c1, hasMoved: false}), King({color: c2, hasMoved: false, inCheck: false}))
 | (King({color: c1, hasMoved: false, inCheck: false}), Rook({color: c2, hasMoved: false})) =>
   c1 === c2
 | _ => false
 }
}

Printing Records

Since records compile to JavaScript objects at runtime, they can be easily printed using Js.log:
type cat = {
 name: string,
 age: int,
 owner: string,
}
let myCat : cat = { name: "Creamsicle", age: 13, owner: "Danny" }
Js.log(myCat)
Console output:
{ "name": "Creamsicle", "age": 13, "owner": "Danny" }

Records and JSON

Among other uses, records can be used to model JSON payloads in our programs. Just like JSON can be parsed into objects in JavaScript, we can parse JSON into records in ReScript.

Serialization

The simplest way to serialize a record to a JSON string is to use Js.Json.serializeExn. As suggested by the name, it will throw at runtime if we pass a value that cannot be serialized:
type cat = {
 name: string,
 age: int,
 owner: string,
}
let myCat : cat = { name: "Creamsicle", age: 13, owner: "Danny" }
let serialized = Js.Json.serializeExn(myCat)
Js.log(serialized)
Console output:
'{ "name": "Creamsicle", "age": 13, "owner": "Danny" }'

Deserialization

The fastest (but not necessarily the safest) way to parse a JSON string into a record is to add a custom binding to JavaScript’s JSON.parse function.

Note that this parsing process is not type-safe and can cause unexpected errors if our JSON string does not actually match the type specified in ReScript. In a later chapter, we will cover how to validate that a JSON payload actually matches the declared types:
type cat = {
 name: string,
 age: int,
 owner: string,
}
// bind to JSON.parse
@scope("JSON") @val
external parseCat: string => cat = "parse"
let parsed = parseCat(`{ "name": "Creamsicle", "age": 13, "owner": "Danny" }`)
Js.log(parsed.name)
Console output:
Creamsicle

Objects

Objects are another way to represent collections of named values in ReScript. They have several key properties that contrast with records:
  • We don’t need to declare types for objects.

  • Objects do not support pattern matching.

  • Objects follow structural typing instead of nominal typing.

Compared to records, objects are harder to manipulate in ReScript, but are better for representing values passed between ReScript and JavaScript, thanks to their flexibility.

Declaring and Creating Objects

Object type declarations and literals are similar to records, except the field names are in quotes. Unlike records, we do not need to declare a named type for objects:
let myHamster = {"age": 1}
type hamster = {
 "age": int,
}
let myHamster : hamster = {"age" : 1}
We can use the fields declared in one object type as part of another object type, using the spread operator. Note that this only works for object type declarations, not for object literals:
type namedHamster = {
 ...hamster,
 "name": string,
}
let myHamster : namedHamster = {"age": 1, "name": "Louie"}
The preceding type declaration for namedHamster is equivalent to:
type namedHamster = {
 "age": int,
 "name": string,
}

Accessing Object Fields

Field accesses use the field name surrounded by quotes and square brackets. We can see this in action in the following example, which calculates the Manhattan distance from the origin on a Cartesian plane:
let manhattan = obj => {
 abs(obj["x"]) + abs(obj["y"])
}
Js.log(manhattan({"x": 5, "y": 6}))
Console output:
11

Structural Typing

Unlike records which have nominal typing, ReScript’s objects have structural typing, which means that typechecking is based on what fields an object has.

The Manhattan function in the previous example can be called on any object as long as it has “x” and “y” fields that are both integers. The typechecker does not care about any extra fields the object has, as long as it has the fields that we need:
manhattan({"x": 5, "y": 10, "name": "Danny"})->Js.log
Console output:
15

In a way, structural typing makes ReScript’s objects feel more similar to JavaScript’s objects. Although JavaScript does not have static typechecking, if we wrote the equivalent Manhattan function in JavaScript, we would also be able to call it on anything that has the required x and y fields. Of course, the main difference is that ReScript’s compiler prevents us from calling the function on objects that don’t have the required fields, while the JavaScript implementation will just crash at runtime!

However, there are times we may want to only allow a function to be called on specific object types. This is where type declarations for objects are useful. By declaring the object’s type and annotating the input, the version defined in the following can only be called on objects that match the coord type exactly, and trying to call it on anything else will cause the typechecker to complain:
type coord = {
 "x": int,
 "y": int,
}
let manhattan2 = (obj: coord) => {
 abs(obj["x"]) + abs(obj["y"])
}
manhattan2({"x": 5, "y": 10, "name": "Danny"})->Js.log
Compiler output:
 This has type: {"x": int, "y": int, "name": string}
Somewhere wanted: coord
The second object type has no method name

Mutating Objects

All objects are immutable except for objects that are imported directly from JavaScript, which can be mutated if the corresponding field in the type declaration is annotated with @set:
// this is raw JavaScript
%%raw("
 var foo = { x: 1 };
")
type myObj = { @set "x": int }
@val external foo: myObj = "foo"
Js.log(foo["x"])
foo["x"] = 2
Js.log(foo["x"])
Console output:
1
2

Printing Objects

Since ReScript objects and JavaScript objects are the same at runtime, they can be easily logged using Js.log:
type catObj = {
 "name": string,
 "age": int,
 "owner": string,
}
let myCat : catObj = { "name": "Creamsicle", "age": 13, "owner": "Danny" }
Js.log(myCat)
Console output:
{ "name": "Creamsicle", "age": 13, "owner": "Danny" }

Objects and JSON

We can serialize and deserialize objects the same way we do with records.

Serializing Objects

Object with declared type:
type catObj = {
 "name": string,
 "age": int,
 "owner": string,
}
let myCat : catObj = { "name": "Creamsicle", "age": 13, "owner": "Danny" }
let serialized = Js.Json.serializeExn(myCat)
Js.log(serialized)
Console output:
'{ "name": "Creamsicle", "age": 13, "owner": "Danny" }'
Object without declared type:
let myCat = { "name": "Creamsicle", "age": 13, "owner": "Danny" }
let serialized = Js.Json.serializeExn(myCat)
Js.log(serialized)
Console output:
'{ "name": "Creamsicle", "age": 13, "owner": "Danny" }'

Deserializing Objects

Object with declared type:
type catObj = {
 "name": string,
 "age": int,
 "owner": string,
}
@scope("JSON") @val
external parseCat: string => catObj = "parse"
let parsed = parseCat(`{ "name": "Creamsicle", "age": 13, "owner": "Danny" }`)
Js.log(parsed["name"])
Console output:
Creamsicle
When deserializing objects without a declared type, the type of the object will be inferred based on which fields are accessed and how they are used:
@scope("JSON") @val
external parseCat: string => 'a = "parse"
let parsed = parseCat(`{ "name": "Creamsicle", "age": 13, "owner": "Danny" }`)
Js.log(parsed["name"])
Console output:
Creamsicle

Objects vs. Records

Although it would be nice to get the benefits of both records and objects in a single data type, that is impossible because we cannot have both structural and nominal typing at the same time. Therefore, as developers we must choose which one is more appropriate for our use case.

Records are a better choice for modeling complex data that we want to manipulate in ReScript thanks to their pattern matching support. On the other hand, objects can be useful for modeling simpler, read-only data and objects/functions that are imported from JavaScript.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset