(Advanced) Implied Types

In the previous section we noticed a problem with the definition of the contract:

Fig 1:


atom IndexOutOfBounds
contract RandomAccess<C, E> {
  function read(collection: C, index: int) -> oneof<E, std::Error<IndexOutOfBounds>>;
  function write(collection: C, index: int, elem: E) -> oneof<std::OK, std::Error<IndexOutOfBounds>>;
}

Specifically, we decided that this contract definition is too unconstrained: knowing the types of the arguments in a RandomAccess::read(...) call is insufficient to know which contract implementation the call should dispatch to.

To drive this point home, in the below example there are two implementations of the contract both over the same collection type, but over different element types. In this definition of the contract, there's nothing stopping this from happening.

Fig 2:


newtype Node<T> : mut struct {
  val: T,
  next: oneof<Node<T>, std::Nothing>
}
function findNode<T>(head: Node<T>, index: int) -> oneof<Node<T>, std::Error<IndexOutOfBounds>> {
  # ...
  if (index < 0) { return std::Error(IndexOutOfBounds); }
  repeat (index) {
    var next = unwrap(head).next;
    if (next instanceof Node<T>) {
      head = next;
    } else {
      return std::Error(IndexOutOfBounds);
    }
  }
  return head;
}

implement RandomAccess<Node<string>, string> {
  function read(head: Node<string>, index: int) -> oneof<string, std::Error<IndexOutOfBounds>> {
    # ...
    var found ?= findNode(head, index);
    return unwrap(found).val;
  }
  function write(head: Node<string>, index: int, elem: string) -> oneof<std::OK, std::Error<IndexOutOfBounds>> {
    # ...
    var found ?= findNode(head, index);
    unwrap(found).val = elem;
    return std::OK;
  }
}
implement RandomAccess<Node<string>, int> {
  function read(head: Node<string>, index: int) -> oneof<int, std::Error<IndexOutOfBounds>> {
    # ...
    var found ?= findNode(head, index);
    return len(unwrap(found).val);
  }
  function write(head: Node<string>, index: int, elem: int) -> oneof<std::OK, std::Error<IndexOutOfBounds>> {
    # ...
    var found ?= findNode(head, index);
    unwrap(found).val = "{elem}";
    return std::OK;
  }
}

As a result, any calls to the RandomAccess::read(...) function are inherently ambiguous, and require the return type to be explicitly, statically constrained. Any unconstrained calls to this contract procedure would result in a compilation error where Claro tries to ask the user which contract implementation they actually intend to dispatch to:

Fig 3:


var myLinkedList = Node(mut {val = "head", next = Node(mut {val = "middle", next = Node(mut {val = "tail", next = std::Nothing})})});

print(RandomAccess::read(myLinkedList, 1));

Compilation Errors:

implied_types_EX3_example.claro:3: Ambiguous Contract Procedure Call: Calls to the procedure `RandomAccess$<C, E>::read` is ambiguous without an explicit type annotation to constrain the expected generic return type `oneof<E, [module at //stdlib:std]::Error<IndexOutOfBounds>>`.
print(RandomAccess::read(myLinkedList, 1));
      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1 Error

Note: This ambiguity is an inherent feature of the RandomAccess<C, E> definition itself. Claro would still produce a compilation error if there happened to only be a single implementation because another conflicting implementation could be added at any time.

Statically Preventing Ambiguous Contract Definitions with Implied Types

Of course, there's arguably very little reason for this particular contract to actually allow multiple implementations over the same collection type (the second implementation RandomAccess<Node<string>, int> above is very contrived). So ideally this contract definition should statically encode a restriction on such implementations. It should only be possible to implement this contract once for a given collection type - meaning that there would be no more ambiguity on the return type of calls to RandomAccess::read(...).

Thankfully, you can encode this restriction directly into contract definition using "Implied Types":

Fig 4:


atom IndexOutOfBounds

# Type `C` implies type `E`. There can only be a single RandomAccess impl for any given `C`.
contract RandomAccess<C => E> {
  function read(collection: C, index: int) -> oneof<E, std::Error<IndexOutOfBounds>>;
  function write(collection: C, index: int, elem: E) -> oneof<std::OK, std::Error<IndexOutOfBounds>>;
}

The only change is in the declaration of the contract's generic type parameters: <C => E> (read: "C implies E") was used instead of <C, E>. This explicitly declares to Claro that this implication must be maintained for all types, C, over which the contract is implemented throughout the entire program.

As a result, it will now be a compilation error for two separate implementations RandomAccess<C, E1> and RandomAccess<C, E2> (where E1 != E2) to coexist, as this would violate the constraint that C => E.

So now, attempting to define the two implementations given in the previous example would result in a compilation error:

Fig 5:


newtype Node<T> : mut struct {
  val: T,
  next: oneof<Node<T>, std::Nothing>
}
function findNode<T>(head: Node<T>, index: int) -> oneof<Node<T>, std::Error<IndexOutOfBounds>> {
  # ...
  if (index < 0) { return std::Error(IndexOutOfBounds); }
  repeat (index) {
    var next = unwrap(head).next;
    if (next instanceof Node<T>) {
      head = next;
    } else {
      return std::Error(IndexOutOfBounds);
    }
  }
  return head;
}

implement RandomAccess<Node<string>, string> {
  function read(head: Node<string>, index: int) -> oneof<string, std::Error<IndexOutOfBounds>> {
    # ...
    var found ?= findNode(head, index);
    return unwrap(found).val;
  }
  function write(head: Node<string>, index: int, elem: string) -> oneof<std::OK, std::Error<IndexOutOfBounds>> {
    # ...
    var found ?= findNode(head, index);
    unwrap(found).val = elem;
    return std::OK;
  }
}
implement RandomAccess<Node<string>, int> {
  function read(head: Node<string>, index: int) -> oneof<int, std::Error<IndexOutOfBounds>> {
    # ...
    var found ?= findNode(head, index);
    return len(unwrap(found).val);
  }
  function write(head: Node<string>, index: int, elem: int) -> oneof<std::OK, std::Error<IndexOutOfBounds>> {
    # ...
    var found ?= findNode(head, index);
    unwrap(found).val = "{elem}";
    return std::OK;
  }
}

Compilation Errors:

Invalid Contract Implementation: The Contract you're attempting to implement is defined as RandomAccess$<C => E> which means that there can only be exactly one implementation of RandomAccess$ for the unconstrained type params C.
		However, the following conflicting implementations were found:
			RandomAccess$<Node<string>, int>
		AND
			RandomAccess$<Node<string>, string>
1 Error

Now, by eliminating one of the implementations you fix the compilation error. In addition, you're now able to call RandomAccess::read(...) without any ambiguity!

Fig 6:


newtype Node<T> : mut struct {
  val: T,
  next: oneof<Node<T>, std::Nothing>
}
function findNode<T>(head: Node<T>, index: int) -> oneof<Node<T>, std::Error<IndexOutOfBounds>> {
  # ...
  if (index < 0) { return std::Error(IndexOutOfBounds); }
  repeat (index) {
    var next = unwrap(head).next;
    if (next instanceof Node<T>) {
      head = next;
    } else {
      return std::Error(IndexOutOfBounds);
    }
  }
  return head;
}

# This is now the only implementation in the entire program.
implement RandomAccess<Node<string>, string> {
  function read(head: Node<string>, index: int) -> oneof<string, std::Error<IndexOutOfBounds>> {
    # ...
    var found ?= findNode(head, index);
    return unwrap(found).val;
  }
  function write(head: Node<string>, index: int, elem: string) -> oneof<std::OK, std::Error<IndexOutOfBounds>> {
    # ...
    var found ?= findNode(head, index);
    unwrap(found).val = elem;
    return std::OK;
  }
}

var myLinkedList = Node(mut {val = "head", next = Node(mut {val = "middle", next = Node(mut {val = "tail", next = std::Nothing})})});

print(RandomAccess::read(myLinkedList, 1));

Output:

middle

Deciding Whether to Use Implied Types to Constrain a Contract's Implementations is a Judgement Call

If you made it through this entire section, you should have a strong understanding of the purpose and value add of implied types. However, keep in mind that both unconstrained and implied types have their uses! In particular, the return type ambiguity demonstrated in this section may actually be used to good effect, particularly in the case of designing more "fluent" APIs.

Don't just assume that every contract should be defined using implied types. You should be applying good design judgement to determine if and when to use this feature or to leave a contract's type parameters unconstrained.