Skip to content
ry

Custom types

Every type so far is structural: two types are the same if they have the same shape. Any list(name = "Ada") is a list{name: character}, and every double is interchangeable with every other. That is the right default for R, where values are plain data, but it cannot tell Celsius from Fahrenheit, or a user ID from an email address.

alias names a shape and keeps it structural, while type declares a nominal type, distinct by name. Both also work for ry’s own records, tuples and tag unions, as Records and tags shows.

A nominal type is distinct by name, even from a type with the same shape. type declares one, and @Money(value) marks the one place that creates its values. The comment spelling is #: @type and #: @new:

#: @type Money {list{amount: double, currency: character}}
#: fn(amount: double, currency: character) -> Money
money <- function(amount, currency) {
if (amount < 0) stop("negative amount")
#: @new Money
structure(list(amount = amount, currency = currency), class = "Money")
}
#: fn(m: Money) -> double
amount_of <- function(m) m$amount
amount_of(list(amount = 1, currency = "EUR"))
type-mismatch
x expected `Money`, found `list{amount: double, currency: character}`. The value has the right shape, but `Money` is a nominal type, so only `@Money(...)` or `#: @new Money` creates one

A list with the right fields is still not a Money. Put @Money(...) in one constructor and nowhere else, and every Money in the program has passed its checks: @Money(...) checks the shape when ry analyzes the code, and stop() checks the values when it runs. At run time @Money(x) is just x.

Scalars work the same way. With type UserId = character and type Email = character, passing an Email where a UserId is expected is an error, although both are strings at run time.

A nominal value is still accepted where its representation is, so nchar() works on a UserId. Arithmetic gives the plain representation back: with type Celsius = double, the sum of two Celsius values is a double, because ry cannot assume that adding two temperatures gives a temperature. Money is a list, so + on it is an error until you declare the operator:

#: fn(a: Money, b: Money) -> Money
`+.Money` <- function(a, b) money(a$amount + b$amount, a$currency)

Now money(1, "EUR") + money(2, "EUR") is a Money. R finds +.Money through the class attribute, which is why the constructor sets one; ry itself needs no class to check the type.

A type can take parameters: with type Page<T> = list{items: list[T], total: integer}, reading items from a Page<integer> gives a list[integer].

alias names a type without making it nominal, so alias Row = list{id: integer} is a shorter way to write the shape.

Definitions belong at the top level of a file. In the comment spelling, a @type or @alias line needs its own #: block, separated from other annotations by a blank line. If you keep both spellings, put the #: block directly above the inline definition: ry reads the pair as one definition and warns when the two disagree.

Existing R6 and S4 classes keep working, but their objects are Unknown, and ry warns on each class definition (object-system) because records and tags model data with every field checked. To check who receives one, give it a nominal type over Any and wrap its constructor:

#: @type Account {Any}
#: fn() -> Account
new_account <- function() {
#: @new Account
AccountClass$new()
}