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 of f2
  • The argument(s) of f2 must be a subtype of the argument(s) of f1

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.