Strict typing & static analysis Rob Allen January 2020

Use Types to Help Focus on What You’re Doing, Not How You’re Doing It Anthony Ferrara, 2016 Rob Allen ~ @akrabat

PHP Types boolean float array callable resource integer string object iterable NULL Rob Allen ~ @akrabat

PHP is a dynamically typed language Rob Allen ~ @akrabat

Type Juggling PHP will automatically convert types when it can. var_dump(“Two + three = ” . 5); Rob Allen ~ @akrabat

Type Juggling PHP will automatically convert types when it can. var_dump(“Two + three = ” . 5); // string(15) “Two + three = 5” Rob Allen ~ @akrabat

Type Juggling PHP will automatically convert types when it can. var_dump(“Two + three = ” . 5); // string(15) “Two + three = 5” var_dump(“10” + 5); Rob Allen ~ @akrabat

Type Juggling PHP will automatically convert types when it can. var_dump(“Two + three = ” . 5); // string(15) “Two + three = 5” var_dump(“10” + 5); // int(15) Rob Allen ~ @akrabat

Rob Allen ~ @akrabat

But… $result = 10 + “10,000”; var_dump($result); Rob Allen ~ @akrabat

But… $result = 10 + “10,000”; var_dump($result); // int(20) Rob Allen ~ @akrabat

But… $result = “false”; if ($result) { echo “TRUE”; } else { echo “FALSE”; } Rob Allen ~ @akrabat

But… $result = “false”; if ($result) { echo “TRUE”; } else { echo “FALSE”; } // TRUE Rob Allen ~ @akrabat

Rob Allen ~ @akrabat

You can’t trust a computer! Rob Allen ~ @akrabat

Test all the things Rob Allen ~ @akrabat

Validate your inputs function foo($i, $b) { if (! is_int($i)) { throw new Exception(‘$i must be an integer’); } if (! is_bool($b)) { throw new Exception(‘$b must be a boolean’); } // … } & write tests for the new code-paths Rob Allen ~ @akrabat

A type system solves this class of errors Rob Allen ~ @akrabat

If we filter input by a type — you can think of it as a subcategory of all available input — many of the tests become obsolete. @brendt_gd Rob Allen ~ @akrabat

Function Type Declarations function foo(int $i, bool $b) : string { // … } • $i is always an integer • $b is always a boolean • foo() will always return a string Rob Allen ~ @akrabat

This function is much easier to reason about Rob Allen ~ @akrabat

Coercion of Typed Declarations function foo(int $i, bool $b) : string { // … } foo(“1”, 1); // $i = int(1) // $b = bool(true) Rob Allen ~ @akrabat

Strictly Typed Declarations declare(strict_types=1); foo(“1”, 1); Fatal error: Uncaught TypeError: Argument 1 passed to foo() must be of the type int, string given Rob Allen ~ @akrabat

This reduces cognitive load Rob Allen ~ @akrabat

Typed Properties in PHP 7.4! We can now add types to class properties class Person { public int $age; } Rob Allen ~ @akrabat

Initialisation of Typed Properties $liz = new Person(); var_dump($liz->age); Rob Allen ~ @akrabat

Initialisation of Typed Properties $liz = new Person(); var_dump($liz->age); Fatal error: Uncaught Error: Typed property Person::$age must not be accessed before initialization Rob Allen ~ @akrabat

Coercion of Typed Properties class Person { /* … */} $liz = new Person(); $liz->age = “93”; // int(93) Rob Allen ~ @akrabat

Strictly Typed Properties declare(strict_types=1); $liz = new Person(); $liz->age = “93”; Fatal error: Uncaught TypeError: Typed property Person::$age must be int, string used Rob Allen ~ @akrabat

But that’s all at runtime… Rob Allen ~ @akrabat

Static Analysis checks our code before we run it Rob Allen ~ @akrabat

Static Analysis Static code analysers simply reads your code and points out errors They ensure: • • • • • no syntax errors classes, methods, functions constants, variables exist no arguments or variables unused DocBlocks make sense data passed to methods are the correct type Rob Allen ~ @akrabat

Static Analysers • Phan • PHPStan • Psalm Rob Allen ~ @akrabat

Phan • • • • • Developed by Rasmus Lerdorf & Etsy Stable Parses entire code base and then analyses Requires php-ast extension Doesn’t parse /** @var Foo $foo */; use assert() instead Rob Allen ~ @akrabat

Phan 1. composer require —dev “phan/phan” 2. vendor/bin/phan —init —init-level=1 3. vendor/bin/phan —memory-limit 2G Rob Allen ~ @akrabat

Phan output Rob Allen ~ @akrabat

Phan output Rob Allen ~ @akrabat

PHPStan • • • • • Developed by Ondřej Mirtes Fast Can analyse subset of code base Autoloads classes Support for framework specific magic methods Rob Allen ~ @akrabat

PHPStan 1. composer require —dev “phpstan/phpstan” 2. vendor/bin/phpstan analyse app lib —level=max Rob Allen ~ @akrabat

PHPStan output Rob Allen ~ @akrabat

PHPStan output Rob Allen ~ @akrabat

Psalm • • • • • Developed by Vimeo Comprehensive Can analyse subset of code base Autoloads classes Support custom PHPDoc tags (e.g. @psalm-param) Rob Allen ~ @akrabat

Psalm 1. composer require —dev “vimeo/psalm” 2. vendor/bin/psalm —init 3. vendor/bin/psalm Rob Allen ~ @akrabat

Psalm output Rob Allen ~ @akrabat

Psalm output Rob Allen ~ @akrabat

Observations • All are good at finding type issues • All three also found found different issues • Phan found unused use statements • PHPStan found logic errors • Psalm is more type-picky, especially with mixed Rob Allen ~ @akrabat

What Problems Do Static Analysers Find? Rob Allen ~ @akrabat

Report 1 class Acl extends BaseAcl { protected $siteKey = ”; protected $settings; public function __construct(array $settings) { $this->settings = $settings; } Rob Allen ~ @akrabat

Report 1 class Acl extends BaseAcl { protected $siteKey = ”; protected $settings; public function __construct(array $settings) { $this->settings = $settings; } INFO: MissingPropertyType - Property Acl::$siteKey does not have a declared type - consider string Rob Allen ~ @akrabat

Report 1: Fix (PHP 7.4) class Acl extends BaseAcl { protected string $siteKey = ”; protected $settings; Add property’s type Rob Allen ~ @akrabat

Report 1: Fix (PHP 7.3) class Acl extends BaseAcl { /** @var string */ protected $siteKey = ”; protected $settings; Use a docblock to declare property’s type Rob Allen ~ @akrabat

Report 2 public function setSiteKey($siteKey) { $this->siteKey = $siteKey; return $this; } Rob Allen ~ @akrabat

Report 2 public function setSiteKey($siteKey) { $this->siteKey = $siteKey; return $this; } INFO: MissingParamType - Parameter $siteKey has no provided type INFO: MissingReturnType - Method setSiteKey does not have a return type, expecting Acl Rob Allen ~ @akrabat

Report 2: Fix public function setSiteKey(string $siteKey) { $this->siteKey = $siteKey; return $this; } Add parameter’s type Rob Allen ~ @akrabat

Report 2: Fix public function setSiteKey(string $siteKey): Acl { $this->siteKey = $siteKey; return $this; } Add parameter’s type & return type Rob Allen ~ @akrabat

An bona fide potential bug! public function populate(): void { $siteKey = $this->getSiteKey() ?: 1; $routes = $this->router->slugRoutes($siteKey); foreach ($routes as $route) { Rob Allen ~ @akrabat

An bona fide potential bug! public function populate(): void { $siteKey = $this->getSiteKey() ?: 1; $routes = $this->router->slugRoutes($siteKey); foreach ($routes as $route) { ERROR: InvalidScalarArgument Argument 1 of slugRoutes expects null|string, int(1)|non-empty-string provided Rob Allen ~ @akrabat

Collections class ChoiceService { public function fetchChoices(): array { … } … usage: foreach ($service->fetchChoices() as $choice) { … } Rob Allen ~ @akrabat

Collections /** * @return Choice[] */ public function fetchChoices(): array { … } Declare type of array returned using a docblock Rob Allen ~ @akrabat

Collections /** * @return Choice[] */ public function fetchChoices(): array { … } ERROR: InvalidReturnStatement - The type ‘array{0: Choice|null}’ does not match the declared return type ‘array<array-key, Choice>’ for ChoiceService::fetchChoices Rob Allen ~ @akrabat

False positive? // loadByKey() returns: ?Choice $choice = $choiceService->loadByKey($keyName); if (!$choice) { $this->redirectToHome(); // throws an Exception } $data = $choice->getArrayCopy(); return $this->format($data); Rob Allen ~ @akrabat

False positive? // loadByKey() returns: ?Choice $choice = $choiceService->loadByKey($keyName); if (!$choice) { $this->redirectToHome(); // throws an Exception } $data = $choice->getArrayCopy(); return $this->format($data); Cannot call method getArrayCopy on possibly null value Rob Allen ~ @akrabat

False positive?: Fix /** * @psalm-return never-return */ public function redirectToHome(): void { // … } Guarantees that the function exits or throws an exception Rob Allen ~ @akrabat

Psalm annotations @psalm-var, @psalm-param & @psalm-return: Psalm versions for types not understood by phpDocumenter never-return adds an implicit exit at end of function Rob Allen ~ @akrabat

Psalm annotations @psalm-var, @psalm-param & @psalm-return: Psalm versions for types not understood by phpDocumenter never-return adds an implicit exit at end of function @psalm-seal-properties: Prevent __set/__get not declared with @property Rob Allen ~ @akrabat

Psalm annotations @psalm-var, @psalm-param & @psalm-return: Psalm versions for types not understood by phpDocumenter never-return adds an implicit exit at end of function @psalm-seal-properties: Prevent __set/__get not declared with @property @psalm-suppress: Suppress specific Psalm issues Full list: https://psalm.dev/docs/annotating_code/supported_annotations Rob Allen ~ @akrabat

Applying Static Analysis to Your Project Rob Allen ~ @akrabat

Applying to Your Project • Add to CI Rob Allen ~ @akrabat

Applying to Your Project • Add to CI • Use levels and fix the “easy” items first • Set to lowest level and fix all issues • Increase level and repeat Rob Allen ~ @akrabat

1745 errors found 3229 other issues found Rob Allen ~ @akrabat

Baselines 1. Log all current errors to a baseline file: vendor/bin/psalm —set-baseline=baseline.xml Rob Allen ~ @akrabat

Baselines 1. Log all current errors to a baseline file: vendor/bin/psalm —set-baseline=baseline.xml 2. Subsequent psalm runs will treat all errors in the baseline file as warnings Rob Allen ~ @akrabat

Baselines 1. Log all current errors to a baseline file: vendor/bin/psalm —set-baseline=baseline.xml 2. Subsequent psalm runs will treat all errors in the baseline file as warnings 3. Fix warnings as you can (and allow no new errors!) Rob Allen ~ @akrabat

Baselines 1. Log all current errors to a baseline file: vendor/bin/psalm —set-baseline=baseline.xml 2. Subsequent psalm runs will treat all errors in the baseline file as warnings 3. Fix warnings as you can (and allow no new errors!) 4. Update baseline as you go: vendor/bin/psalm —update-baseline Rob Allen ~ @akrabat

To Sum Up Rob Allen ~ @akrabat

Takeaways • • • • • • PHP 7’s type declarations reduce cognitive load Upgrade to PHP 7.4 for typed properties! Static analysis finds bugs in your code Use plugins to give additional info to your static analyser Autofixers are great! e.g. psalter, rector Add static analysis to your CI Rob Allen ~ @akrabat

Thank you! Rob Allen ~ @akrabat