Custom types
Defining custom types
Basics
Aiken's custom types are named collections of keys and/or values. They are similar to objects in object-oriented languages, though they don't have methods.
Custom types are defined with the type
keyword. They may contain named
fields, or not; but they cannot mix.
// With named fields
type Datum {
Datum { signer: ByteArray, count: Int }
}
// With unnamed fields
type DatumNameless {
DatumNameless(ByteArray, Int)
}
Here we have defined two custom types called Datum
and DatumNameless
respectively.
The constructor of Datum
is called Datum
and it has two fields: A
signer
field which is a ByteArray
, and a count
field which
is an Int
. Likewise, the constructor of DatumNameless
also has two fields
of types ByteArray
and Int
.
Once defined the custom type can be used in functions to create values by calling their constructors.
fn datums() {
// Named fields can be given in any order
let named_1 = Datum { signer: #[0xAA, 0xBB], count: 2001 }
let named_2 = Datum { count: 1805, signer: #[0xAA, 0xCC] }
// Nameless fields are given as positional arguments
let nameless_1 = DatumNameless(#[0xAA, 0xBB], 2001)
//Event-named fields can be given as positional arguments.
let named_3 = Datum(#[0xAA, 0xBB], 2001)
(named_1, named_2, nameless_1, named_3)
}
Shorthand notation
Because single constructors are quite common, there exists a special shorthand notation when the type and the constructor have the same name. So instead of the above, one can write:
type Datum {
signer: ByteArray,
count: Int
}
These two notations (with or without the constructor) are synonyms. With the
shorthand, we implicitly indicate that there's a single constructor named
Datum
which can be used for constructing values of type Datum
, or can also
be used when destructuring (see below).
Multiple constructors
Custom types in Aiken can be defined with multiple constructors (a.k.a variants), making them a way of modeling data that can be one of a few different variants.
We've already seen custom types with multiple constructors in the Language
Tour like Bool
or Option
.
Aiken's built-in Bool
type is defined like this:
/// A Bool is a value that is either `True` or `False`
type Bool {
False
True
}
It's a simple custom type with constructors that takes no arguments at all! Use
it to answer yes/no questions and to indicate whether something is True
or False
.
The records created by different constructors for a custom type can contain
different values. For example, a User
custom type could have a LoggedIn
constructor that creates records with a name, and a Guest
constructor which
creates records without any contained values.
type User {
LoggedIn { username: ByteArray } // A logged in user
Guest // A guest user with no details
}
let user1 = LoggedIn { username: "Alice" }
let user2 = LoggedIn { username: "Bob" }
let visitor = Guest
Generics
Custom types can be parameterized with other types, making their contents
variable. We've seen that with the Option
.
Let consider another example with a Box
type that is a simple record that
holds a single value.
type Box<inner_type> {
Box(inner: inner_type)
}
The type of the field inner
is inner_type
, which is a parameter of the Box
type. If it holds an Int
the box's type is Box<Int>
, if it holds a string the
box's type is Box<ByteArray>
.
fn foo() {
let a = Box(420) // type is Box<Int>
let b = Box("That's my ninja way!") // type is Box<ByteArray>
}
Inspecting custom types
Named accessors
If a custom type has only one constructor and named fields they can be
accessed using the dot symbol (.
), followed by the name of the field.
For example, considering a type Dog
:
type Dog {
name: ByteArray,
cuteness: Int,
age: Int,
}
We can access any of its fields using .name
, .cuteness
and
.age
respectively.
let dog = Dog { name: "bob", cuteness: 2001, age: 6 }
dog.name // This returns "bob"
dog.cuteness // This returns 2001
dog.age // This returns 6
Destructuring
Values can be can be destructured in Aiken. Destructuring is the opposite of constructing a value and uses a similar syntax albeit reversed. When constructing, constructors and fields appears on the right-hand side of an assignment. When destructuring, they appear on the left-hand side.
To keep rolling with our Dog
example, we have the following equivalence:
// Constructing
let dog = Dog { name: "bob", cuteness: 2001, age: 6 }
// Destructuring
let Dog { name, cuteness, age } = dog
name == "bob" // True
cuteness == 2001 // True
age == 6 // True
// Equivalent to
let name = dog.name
let cuteness = dog.cuteness
let age = dog.age
As you can see, the second expression introduces three new bindings in scope
for name
, cuteness
and dog
respectively. Destructuring
works here because the associated type has a single constructor and the
identifiers we introduce have the same names as the fields. If needed, we can
however rename fields using a colon symbol (:
), like so:
// Destructuring
let Dog { name: its_name, cuteness, age: its_age } = dog
its_name == "bob" // True
its_age == 6 // True
Note that we can also destructure constructors whose fields are nameless by introducing identifiers for each of the fields. Like for constructing, when treated as nameless fields, the arguments are positional.
// Destructuring nameless
let Dog(name, cuteness, age) = dog
name == "bob" // True
cuteness == 2001 // True
age == 6 // True
// Destructuring nameless, arguments swapped.
let Dog(age, name, cuteness) = dog
age == "bob" // Confusing, but True
name == 2001 // Confusing, but True
cuteness == 6 // Confusing, but True
Pattern-matching
For nameless fields, one must resort to pattern-matching using the when *expr* is
keywords. This syntax allows the inspection of a value following the
various branches defined by a type, ensuring that all possible paths are
properly handled. Said differently, it is like asking the compiler "If the
data has this shape then do that", for all possible shapes.
Recall our User
type from before?
type User {
LoggedIn { username: ByteArray }
Guest
}
Let's write a function get_name
that pulls out the name of a User
:
fn get_name(user: User) -> ByteArray {
when user is {
LoggedIn { username } -> username
Guest -> "Guest user"
}
}
The when *expr* is
block forces us to exhaustively handle every
constructor in the type definition.
Wildcard
Patterns always need to be complete, but enumerating every single fields or constructor can sometime be cumbersome. For these situations, Aiken allows the use of wildcards.
A wildcard is like a fallback pattern, denoted _
and it can be used in place
of a pattern to match any remaining patterns. For example:
fn get_name(user: User) -> ByteArray {
when user is {
LoggedIn { username } -> username
_ -> "Guest user"
}
}
Note that wildcards can also be named. And generally speaking, any identifier
that starts with an underscore _
will be treated as a wildcard.
Beware though that wildcards are usually not recommended because they make code
more brittle. Indeed, if you were to add a new constructor to the type User
,
this function would generate no warnings or errors at compilation because the
wildcard _
would swallow all the remaining constructors. Yet, imagine the following:
type User {
LoggedInAsAdministrator { username: ByteArray }
LoggedIn { username: ByteArray }
Guest
}
With the wildcard, get_name
would compile just fine and return "Guest user"
for users logged in as administrator! So, use wildcard only when you cannot do
otherwise. In many situations, it is better to list all patterns explicitly.
Wildcards also works when destructuring, should you need to only bring specific fields in scope but not all.
let Dog(name, _cuteness, _age) = dog
// equivalent to
let name = dog.name
Alternative patterns
To avoid repeating the same bits of logic across multiple branches, Aiken
provides a syntax for handling multiple patterns at once using the pipe symbol
|
. This works particularly well when patterns introduce the same
identifiers with the same respective types in scope.
fn get_name(user: User) -> ByteArray {
when user is {
LoggedInAsAdministrator { username } | LoggedIn { username } -> username
Guest -> "Guest user"
}
}
Spread operator
In a similar fashion, it is sometimes useful to pull only specific named fields
out of a constructor or even, none at all. For these situations, Aiken provides
the spread operator ..
as a way to indicates that anything else is ignored.
For example, let's pretend we added a field age: Int
to our LoggedIn
constructor variant.
fn is_authorized(user: User) -> Bool {
when user is {
LoggedInAsAdministrator { .. } -> True
LoggedIn { days_of_activity, .. } -> days_of_activity > 30
Guest -> False
}
}
In the function above, you can see how we authorize any administrator or any logged-in user so long as they have at least 30 days of activity. The spread operator is used to indicate that other named fields of the records are ignored.
The spread operator also works for destructuring, should you need to only bring specific fields in scope but not all.
let Dog { name, .. } = dog
// equivalent to
let name = dog.name
List
Pattern-matching also works on lists with a syntax of their own. In Aiken, lists are linked-lists, so they are virtually equivalent to a custom-type with two constructors: either it is an empty list, or it is a value and another list, possibly empty.
When matching on lists, one may use wildcard and spread operators. Yet in the case of lists, the spread operator can be named to explicitly capture the tail of the list. Let's walk through some examples:
fn get_head(xs: List<a>) -> Option<a> {
when xs is {
[] -> None
[a, ..] -> Some(a)
}
}
fn is_empty(xs: List<a>) -> Bool {
when xs is {
[] -> True
[_, ..] -> False
}
}
fn get_tail(xs: List<a>) -> List<a> {
when xs is {
[] -> [] // debatable
[_, ..tail] -> tail
}
}
Nested patterns
Patterns aren't limited to the first level of a type structure only. It is possible to pattern any compound type as deep as necessary.
fn get_name_with_default(dog: Option<Dog>, default: ByteArray) -> ByteArray {
when dog is {
Some(Dog { name, .. }) -> name
_ -> default
}
}
Assigning names to sub-patterns
Sometimes when pattern-matching we want to assign a name to a value while
specifying its shape at the same time. We can do this using the as
keyword.
when xs is {
[[_, ..] as inner_list] -> inner_list
_other -> []
}
Updating custom types
Aiken provides a dedicated syntax for updating some of the fields of a custom type record.
type Person {
name: ByteArray,
shoe_size: Int,
age: Int,
is_happy: Bool,
}
fn have_birthday(person) {
// It's this person's birthday, so increment their age and
// make them happy
Person { ..person, age: person.age + 1, is_happy: True }
}
The update syntax created a new record with the values of the initial record. It replaces the given binding with their new values.
Relationship with Data
At runtime custom types become an opaque Plutus' Data. In Aiken's type system
Data
matches with any user-defined type (but with none of the primitive
types).
Upcasting
Thus, it's also possible to cast any custom type into Data
(a.k.a
upcasting). This implicit conversion is handy for interacting with builtin
functions that operate on raw Data
. Any function that accepts
Data
will automatically work with any custom type.
type Datum {
count: Int,
}
let datum: Datum = Datum { count: 1 }
// fn(Data) -> ByteArray
builtin.serialise_data(datum)
// Or similarly, by providing an annotation.
let as_data: Data = datum
Downcasting
Extracting from Data
into a custom type (a.k.a downcasting)
however requires the use of expect
or an if *pattern* is
as explained in the next section:
Control Flow.
While upcasting is always possible and safe, downcasting is not and can fail. How one chooses to handle failure drives the choice between expect
(fail immediately and loudly) and if/is
(fallback gracefully).
In summary:
From | To | Crash on failure? | How |
---|---|---|---|
any custom type | Data | - (cannot fail) | let-binding + type-annotation |
Data | any custom type | no | if *pattern* is |
Data | any custom type | yes | expect |
Type aliases
Finally, it is also possible to define mere aliases for types. A type alias lets you create a name that is identical to another type, without any additional information.
type MyNumber = Int
Aliases are fully interchangeable and bear no difference at runtime. They are most useful for simplifying type signatures.
type Person = (String, Int)
fn create_person(name: String, age: Int) -> Person {
(name, age)
}
While they are fully erased at runtime, type-aliases information are preserved during type-checking and when generating the validator(s) final blueprint(s). Said differently, type aliases will appear in error messages and generated documentation as well.
Documentation
You may add user-facing documentation to any data-type definition and
constructor with a documentation comment starting with ///
. Markdown is
supported and this text will be included with the module's entry in generated
HTML documentation.
/// A **incredibly useful** comment to tell you that this type represents: a person.
type Person {
/// The number of years elapsed since birth.
age: Int,
/// The word other people shout to catch this person's attention
name: String,
}
When exported as module documentation, types-definition are alphabetically sorted (in ascending order).