DEV Community

Aleksander Wons
Aleksander Wons

Posted on

A few words on type checking at runtime

This is a copy of an old post on my private blog from July 2023.

Some ancient history

I was lucky enough to start my journey with PHP already at version 4. We were finally getting some OOP and stuff. But back then, PHP was still a weakly typed language (it still is, but we can change that if we want to). The dynamic nature of PHP made it possible to assign different types to the same property at runtime. There were only two ways to ensure we get the type we expected:

  • tests - back then, only a minority of PHP developers wrote tests
  • use the instanceof operator or functions like is_string

And so it went. People either didn't care and let things crash in unexpected ways at runtime, or they used those functions and the instanceof operator to make sure they really got what they expected.

Let's take this as an example:

class Foo
{
  function doSomethingElse(){}
}

class Bar
{
  var $foo;

  function __construct($foo){
    $this->foo = $foo;
  }

  function getFoo(){
    $this->foo;
  }

  function doSomething(){
    $this->foo = 1;
  }
}

$bar = new Bar(new Foo());
$foo = $bar->doSomethig();
$foo = $bar->getFoo();
$foo->doSomethingElse();
Enter fullscreen mode Exit fullscreen mode

Here, the only way to know if the method getFoo() returned an instance of Foo is to either have a test or use the instanceof operator. Otherwise, our code blows up on runtime.

The modern times of PHP

Let's fast-forward 15 years when PHP 7 has already reached EOL, and PHP 8.3 (at the time of the writing) is the latest and greatest. And where tests are (or at least I genuinely hope so) are rather a norm than an exception.

I expected that as long as we use strict typing and tests, the usage of instanceof and other type-checking functions would be minimal. Please don't get me wrong. I totally understand that this is sometimes the way to go. While we almost always can get away without them, there might be some cases where we restore to such tactics for pragmatic reasons. But these would be exceptions.

I was somehow surprised to notice interesting ways and arguments why developers were still using those technics.

Example 1

enum Foo: string {
  case FOO_1 = 'foo_1';
  case FOO_2 = 'foo_2';
}

readonly class Bar
{
  public function __construct(public ?Foo $foo, public ?string $baz) {}
}

class MyService
{
  public function doSomething(Bar $bar): array
  {
    $data = [];
    if ($bar->foo instanceof Foo) {
      $data['foo'] = $bar->foo->value;
    }
    if (is_string($bar->baz)) {
      $data['baz'] = $bar->baz;
    }

    return $data;
  }
}
Enter fullscreen mode Exit fullscreen mode

The data was later used so that both foo and baz were expected to be strings (if the values were present at all). This was done because we cannot just check for if ($bar->foo) or if ($bar->baz) because this does not guarantee that the value we get will be a string.

Let me try to polemize with this reasoning.

Let's take instanceof as an example. The problem I see there is that the fact that an object is an instance of a specific class tells us nothing about whether it can be represented as a string. In this case, we do not cast the object to a string but must use the public property value. To be complete, we must check if that property exists and is of a specific type. Checking that would require us to use reflection at runtime with EVERY invocation.

Last but not least - how do we know if the property foo even exists on Bar? Should we check that as well? Not practical, IMO.

Something similar could be said about the property baz. It's not as extreme as foo, though. We would only need to check if the property exists on Bar and if it is_string. We still wouldn't get away without reflection, but it is more straightforward.

This strongly resembles PHP 4, when we could change any type at runtime. However, the code above does not allow us to do this.

However, one could raise another argument: "But what if someone changes the property type? My code makes a wrong assumption now and will not work as expected."

This is a valid argument. Let's alter the code to something like this:

enum Foo: int
{
  case FOO_1 = 1;
  case FOO_2 = 2;
}
Enter fullscreen mode Exit fullscreen mode

If we do not do the checks we discussed before, our output is not what we wanted. Instead of a string, we now have an integer.

But is there a different way to ensure the result is as expected? I would say yes. All we need is a test case.

class Test extends TestCase
{
  public function test(): void
  {
    $foo = Foo::FOO_1;
    $bar = new Bar($foo, 'baz');
    $myService = new MyService();

    $result = $myService->doSomething($bar);
    self::assertSame(['foo' => 'foo_1', 'baz' => $baz], $result);
  }
}
Enter fullscreen mode Exit fullscreen mode

There is no way to keep the test green when altering the code. We would always need to adjust the test. This is a guarantee that what we were doing in MyService will result in a data structure as expected. There is no need to check if something is a string or a specific type. Especially not at runtime. Because it will never change at runtime. And if we temper with the code, our tests will tell us we did something wrong.

This could easily be extended to more situations, like when we return an optional from a method. If our signature tells us we get ?MyClass, then all we need to do is check if the value is not null. If we wrote a decent test checking for side effects, it can't be anything else than MyClass.

Top comments (0)