Unwrappers

Initializers are a very useful concept, but on their own they don't allow full control over maintaining a mutable type's semantic invariants or constraints. For example, consider the following type definition:

Fig 1:


# person.claro_module_api
newtype Person : mut struct {name: string, age: int}

atom InvalidAge
initializers Person {
  function getPerson(name: string, age: int) -> oneof<Person, std::Error<InvalidAge>>;
}

If we wanted to impose the semantic constraint on legal values for a Person's age, defining the initializer alone is only sufficient to ensure the constraint is enforced for the initial value. But it doesn't help maintain this after init as users could still freely unwrap and mutate the type directly:

Fig 2:


# Negative age can be rejected on init...
var invalidAge = Person::getPerson("Jason", -1);
print(invalidAge);

var p = Person::getPerson("Jason", 29);
# Prove that we didn't get a std::Error<InvalidAge>.
if (p instanceof Person::Person) {
  print(p);

  # But we can violate the semantics of the type by unwrapping and mutating directly.
  unwrap(p).age = -1;
  print(p);
}

Output:

[0.001s][warning][perf,memops] Cannot use file /tmp/hsperfdata_runner/6 because it is locked by another process (errno = 11)
Error(InvalidAge)
Person(mut {name = Jason, age = 29})
Person(mut {name = Jason, age = -1})

Fortunately, Claro provides a couple different ways to actually control semantic constraints/invariants like this. The first approach is to define Unwrappers. Analogous to Initializers that constrain the usage of a Type's default constructor, Unwrappers constrain the usage of the built-in unwrap(...) operation. For example, the above violation of the intended constraints on a Person's age can be enforced by adding an Unwrapper procedure that will handle all allowed updates:

Fig 3:


# person.claro_module_api
newtype Person : mut struct {name: string, age: int}

atom InvalidAge
initializers Person {
  function getPerson(name: string, age: int) -> oneof<Person, std::Error<InvalidAge>>;
}
unwrappers Person {
  function setAge(p: Person, newAge: int) -> oneof<std::OK, std::Error<InvalidAge>>;
}

And now, the workaround that previously allowed violating the type's constraints has been patched. Attempts to directly mutate the value w/o going through approved procedures that handle updates will be rejected at compile-time:

Fig 4:


# Negative age can be rejected on init...
var invalidAge = Person::getPerson("Jason", -1);
print(invalidAge);

var p = Person::getPerson("Jason", 29);
# Prove that we didn't get a std::Error<InvalidAge>.
if (p instanceof Person::Person) {
  print(p);

  # But we can violate the semantics of the type by unwrapping and mutating directly.
  unwrap(p).age = -1;
  print(p);
}

Compilation Errors:

unwrappers_EX4_example.claro:11: Illegal Use of User-Defined Type Unwrapper Outside of Unwrappers Block: An unwrappers block has been defined for the custom type `[module at //mdbook_docs/src/module_system/module_apis/type_definitions/initializers_and_unwrappers/unwrappers:person_with_unwrappers]::Person`, so, in order to maintain any semantic constraints that the unwrappers are intended to impose on the type, you aren't allowed to use the type's default `unwrap()` function directly.
		Instead, to unwrap an instance of this type, consider calling one of the defined unwrappers:
			- Person::setAge
  unwrap(p).age = -1;
  ^^^^^^^^^
1 Error

Now, if you actually tried to update the age to something invalid using the official setAge(...) function, the update will be rejected:

Fig 5:


var p = Person::getPerson("Jason", 29);
if (p instanceof Person::Person) {
  print(p);

  # Now the update must be done via the provided `setAge(...)` function
  # which first validates the update, and in this case rejects.
  var updateRes = Person::setAge(p, -1);
  print(updateRes);
  print(p);
}

Output:

Person(mut {name = Jason, age = 29})
Error(InvalidAge)
Person(mut {name = Jason, age = 29})

It's worth noting that initializers and unwrappers blocks exist largely to be used independently. The above example is fairly contrived, and would likely be better defined as an "Opaque Type". A good rule of thumb is that if you catch yourself thinking that you need to define both for the same Type, you should likely be defining the Type to be "Opaque" instead.

In particular, initializers can be well-used in isolation for immutable Types where you would like to validate the values on init, but would like to maintain the ergonomics of allowing users to directly access the internals themselves (and as the data is immutable, there's no risk in allowing them to do so). For example, with the immutable type newtype GameLocation : struct {x: int, y: int} you may want to require that x and y are actually within the game's boundaries, but otherwise you want to allow users of the type to directly access x and y without having to write/use annoying "getters".

On the other hand, unwrappers can be well-used in isolation for mutable values that can start with any value, but for which all subsequent changes must be constrained. For example, with newtype MonotonicallyIncreasingValue: mut struct {val: long} you may be happy to allow arbitrary starting values, but after that point you would want to ensure that any updates to its value are in fact increasing its value, perhaps by simply exposing an Unwrapper like consumer increment(count: MonotonicallyIncreasingValue);.