Sub-typing
Motoko's modern type system allows us to think of some types as being either a subtype or a supertype. If a type T
is a subtype of type U
, then a value of type T
can be used everywhere a value of type U
is expected.
To express this relationship we write:
T <: U
T
is a subtype of U
. And U
is a supertype of T
.
Nat
and Int
The most common subtype in Motoko is Nat
. It is a subtype of Int
, making Int
its supertype.
A Nat
can be used everywhere an Int
is expected, but not the other way around.
func returnInt() : Int {
0 : Nat;
};
The function returnInt
has to return an Int
or some subtype of Int
. Since Nat
is a subtype of Int
, we could return 0 : Nat
where an Int
was expected.
Here's another example of using a Nat
where an Int
is expected:
type T = (Nat, Int, Text);
let t : T = (1 : Nat, 2 : Nat, "three");
Our tuple type T
takes an Int
as the second element of the tuple. But we construct a value with a Nat
as the second element.
We can not supply a Nat
for the third element, because Nat
is not a subtype of Text
. Neither could we supply a negative number like -10 : Int
where a Nat
or Text
is expected, because Int
is not a subtype of Nat
or Text
.
Subtyping variants
A variant V1
can be a subtype of some other variant V2
, giving us the relationship V1 <: V2
. Then we could use a V1
everywhere a V2
is expected. For a variant to be a subtype of another variant, it must contain a subset of the fields of its supertype variant.
Here's an example of two variants both being a subtype of another third variant:
type Red = {
#red : Nat8;
};
type Blue = {
#blue : Nat8;
};
type RGB = {
#red : Nat8;
#blue : Nat8;
#green : Nat8;
Variant type RGB
is a supertype of both Red
and Blue
. This means that we can use Red
or Blue
everywhere a RGB
is expected.
func rgb(color : RGB) { () };
let red : Red = #red 255;
rgb(red);
let blue : Blue = #blue 100;
rgb(blue);
let green : RGB = #green 150;
rgb(green);
We have a function rgb
that takes an argument of type RGB
. We could construct values of type Red
and Blue
and pass those into the function.
Subtyping objects
The subtype-supertype relationship for objects and their fields, is reversed compared to variants. In contrast to variants, an object subtype has more fields than its object supertype.
Here's an example of two object types User
and NamedUser
where NamedUser <: User
.
type User = {
id : Nat;
};
type NamedUser = {
id : Nat;
name : Text;
};
The supertype User
only contains one field named id
, while the subtype NamedUser
contains two fields, one of which is id
again. This makes NamedUser
a subtype of User
.
This means we could use a NamedUser
wherever a User
is expected, but not the other way around.
Here's an example of a function getId
that takes an argument of type User
. It will reference the id
field of its argument and return that value which is expected to be a Nat
by definition of the User
type.
func getId(user : User) : Nat {
user.id;
};
So we could construct a value of expected type User
and pass it as an argument. But an argument of type NamedUser
would also work.
let user : User = { id = 0 };
let id = getId(user);
let namedUser : NamedUser = {
id = 1;
name = "SamerWeb3";
};
let id1 = getId(namedUser);
Since NamedUser
also contains the id
field, we also constructed a value of type NamedUser
and used it where a User
was expected.
Backwards compatibility
An important use of subtyping is backwards compatibility.
Recall that actors have actor interfaces that are basically comprised of public shared functions and public types in actors. Because actors are upgradable, we may change the interface during an upgrade.
It's important to understand what kind of changes to actors maintain backwards compatibility with existing clients and which changes break existing clients.
NOTE
Backwards compatibility depends on the Candid interface of an actor. Candid has more permissive subtyping rules on variants and objects than Motoko. This section describes Motoko subtyping.
Actor interfaces
The most common upgrade to an actor is the addition of a public shared function. Here are two versions of actor types:
type V1 = actor {
post : shared Nat -> async ();
};
type V2 = actor {
post : shared Nat -> async ();
get : shared () -> async Nat;
};
The first actor only has one public shared function post
. We can upgrade to the second actor by adding the function get
. This upgrade is backwards compatible, because the function signature of post
did not change.
Actor V2
is a subtype of actor V1
(like in the case of object subtyping). This example looks similar to object subtyping, because V2
includes all the functions of V1
. We can use V2
everywhere an actor type V1
is expected.
Breaking backwards compatibility
Lets demonstrate an upgrade that would NOT be backwards compatible. Lets upgrade to V3
:
type V3 = actor {
post : shared Nat -> async ();
get : shared () -> async Int;
};
We only changed the return type of the get
function from Nat
to Int
. This change is NOT backwards compatible! The reason for this is that the new version of the public shared function get
is not a subtype of the old version.
Subtyping public shared functions
Public shared functions in actors can have a subtype-supertype relationship. A public shared function f1
could be a subtype of another public shared function f2
, giving us the the relationship f1 <: f2
.
This means that we can use f1
everywhere f2
is expected, but not the other way around. But more importantly, we could safely upgrade from the supertype f2
to the subtype f1
without breaking backwards compatibility.
Lets look at an example where f1 <: f2
:
public shared func f1(a1 : Int) : async Nat { 0 : Nat };
public shared func f2(a2 : Nat) : async Int { 0 : Int };
Function f1
is a subtype of f2
. For this relationship to hold, two conditions must be satisfied:
- The return type of
f1
must be a subtype of the return type off2
- The argument(s) of
f2
must be a subtype of the argument(s) off1
The return type Nat
of function f1
is a subtype of return type Int
of function f2
.
The argument Nat
of function f2
is a subtype of argument Int
of function f1
.
The types of f1
and f2
in Motoko are:
type F1 = shared Int -> async Nat;
type F2 = shared Nat -> async Int;
Upgrading a public shared function of type F2
to a public shared function of type F1
is considered a backwards compatible upgrade.
Since F1
is a subtype of F2
and public shared functions are a shared type, we could use these types like this:
public shared func test() : async F2 { f1 };
We are returning f1
of type F1
where a F2
is expected.
Upgrading public shared functions
Suppose we have the following types:
type Args = {
#a;
#b;
};
type ArgsV2 = {
#a;
#b;
#c;
};
type Result = {
data : [Nat8];
};
type ResultV2 = {
data : [Nat8];
note : Text;
};
Variant Args
is a subtype of variant ArgsV2
. Also, object type ResultV2
is a subtype of object type Result
.
We may have a function that uses Args
as the argument type and Result
as the return type:
public func post(n : Args) : async Result { { data = [0, 0] } };
We could upgrade to a new version with types ArgsV2
and ResultV2
without breaking backwards compatibility, as long as we keep the same function name:
public func post(n : ArgsV2) : async ResultV2 {
{ data = [0, 0]; note = "" };
};
This is because Args
is a subtype of ArgsV2
and Result
is a supertype of ResultV2
.