Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Runtime Type Checks

Whim keeps type data while a program runs. It checks types at each boundary that has a declared type.

function double(int $value): int {
  return $value * 2;
}

assert!(double(4) == 8);

Calling double('4') throws Whim\Unwind\TypeError. Whim does not turn the string into an integer.

Where Whim checks types

Whim checks:

  • function and method arguments;
  • return values;
  • property defaults and writes;
  • static properties and constants;
  • enum backing values;
  • generic type arguments and their bounds;
  • collection writes when a typed place holds the collection;
  • is, as, ?as, match patterns, and typed catch clauses;
  • class, interface, and override contracts.

The compiler may prove that a check will pass and remove it. This changes no result. A check remains when the compiler cannot prove the type.

Type errors do not change the old value

Whim prepares a write, runs the operation, checks the result, then stores it. If any step throws, the old place stays unchanged.

class Counter {
  public int $value = 1;
}

$counter = new Counter();
$counter->value = 'wrong'; // throws; value remains 1

This rule also covers nested array writes and compound assignments.

is

$value is T returns true when the value fits T.

function describe(int|string $value): string {
  if ($value is int) {
    return 'integer ' . $value;
  }

  return 'string ' . $value;
}

The value expression runs once. The type may contain aliases, integer ranges, string length limits, collections, type parameters, and symbol types.

An unknown name may run the autoloader. If the name remains unknown, the check returns false instead of throwing. A later declaration can make a later check match.

Whim permits some bare generic names in a class test:

final class Box<T> {}

$box = new Box::<string>();
assert!($box is Box);
assert!($box is Box<_>);

Built-in collection names need their full number of type arguments in a runtime check. Write vec<_>, dict<_, _>, or array<_, _>, not bare vec, dict, or array.

as

$value as T first checks the whole destination type. If the value already fits, it keeps its value and numeric kind. Otherwise it tries numeric conversion and throws TypeError if no valid conversion fits.

$value = 42 as int;

Numeric conversions follow these rules:

FromToRequirement
intuintzero or greater
uintintat most Whim\Math\INT_MAX
int or uintfloatrounds to the nearest binary64 value
floatintfinite, whole, and in [-2^63, 2^63)
floatuintfinite, whole, and in [0, 2^64)

Casts never wrap or truncate a fractional float. They do not parse strings, convert booleans, or convert the elements of a collection.

assert!((42 as uint) == 42u);
assert!((42u as int) == 42);
assert!((42u as float) == 42.0);
assert!((42.0 as uint) == 42u);
assert!((42u as (int|uint)) is uint);
assert!((42.0 as (uint|int)) == 42u);
assert!((42.0 as (int|uint)) == 42);
assert!((1.5 ?as uint) == null);

When conversion is needed, a union tries its numeric branches in source order. Aliases and type parameters follow the types they name. Each result must fit its branch, including range and literal limits. Intersections retain all their constraints; a negated type alone does not choose a numeric conversion. For example, 42.0 as (0u..=10u|int) produces signed 42.

Float conversion can lose integer precision. Whim\Math\UINT_MAX as float rounds to 2^64, which cannot convert back to uint.

A cast to a newtype adds that newtype’s tag after checking the backing type. It does not convert an incompatible backing value: convert to uint before tagging a signed value with a uint-backed newtype.

?as

$value ?as T follows the same checks and numeric conversions, returning null when they fail.

$raw = 'not an integer';
$timeout = ($raw ?as int) ?? 30;

This form does not throw for a type mismatch. It may still throw while evaluating $value.

Deep checks

Collection and generic checks inspect their parts when the type calls for it.

$values = vec[1, 2];
assert!($values is vec<int>);

$values[] = 'changed';
assert!(!($values is vec<int>));
assert!($values is vec<int|string>);

The same rule applies to dict keys and values, tuple positions, shape types, object type arguments, and newtype backing values.

An empty vec has type vec<never>. An empty dict has type dict<never, never>. Since never fits every type, empty collections fit any compatible element type.

Reified generic types

An object keeps its type arguments:

final class Box<T> {
  public function __construct(public T $value) {}
}

$box = new Box::<int>(1);
assert!($box is Box<int>);
assert!(!($box is Box<string>));

Function and method calls also keep their bound type arguments for the length of the call. Code may test a value against a type parameter with is T.

Relative class types

Inside a class:

  • self is the declaring class with its bound type arguments;
  • parent is its direct parent;
  • static is the runtime class on which the call began.

static may appear as a return type but not a parameter type. Use self for a parameter that accepts the declaring class and its children.

Type identifiers

Whim\Type gives code an opaque integer key for a type.

use Whim\Type;

function same_type<T>(T $value): bool {
  return Type\of($value) == Type\id::<T>();
}

assert!(same_type::<int>(42));

Type\of($value) describes the value’s runtime type. Type\id::<T>() describes T. Equal types have equal identifiers in one engine.

Use a type identifier only for equality or as an array key. Its integer value has no public meaning. Do not store it for another process or run.

Loading and checks

Whim can compile a type name before that symbol loads. Once an autoloader or a file declares the symbol, later checks use its real type. This is why Whim does not require all type names to exist when it first parses a file.

Links between declared classes and interfaces are stricter. Whim checks their inheritance and member contracts when it links them.