...and how to avoid it.
Type coverage is a way to gradually add type declarations to your PHP project—step by step, one by one. It's a PHPStan package that helps you maintain a specific minimal level from 0 % to 100 %.
Once we reach high coverage of 80-90 %, we feel safer. But our code can actually be in worse, even dangerous, shape.
Imagine we have the following PHP 8.2 code with 100 % type coverage:
<?php
final readonly class Price
{
public function __construct(
private int $price,
private int $decimalCount
) {
// ...
}
}
It looks like very modern PHP, right?
But what if we pass values via controller route params or external service API?
$price = new Price('100.82', '2');
What will be the price value? We'd love to see 100.82
float, right?
This is what our code really does (see 3v4l.org):
$price = new Price((int) '100.82', (int) '2');
Even if we have 100 % type coverage, all we've done is add typecasting to our codebase:
return (int) $value + (string) $anotherValue + (float) $thirdValue;
Seeing it like this is an obvious red flag, but without using declare(strict_types=1)
, PHP will silently continue. If we're lucky and we have enabled deprecation warning to trigger errors, we might see:
Deprecated: Implicit conversion from float-string "100.82" to int loses precision on line 5
But this is usually lost because we must run our tests with enabled deprecation warnings.
We already used a step-by-step approach to reach 100 % type coverage with the help of the PHPStan package. The configuration is in the phpstan.neon
, and we know what level of type coverage we have.
parameters:
type_coverage:
return: 90
param: 90
property: 90
Those numbers were once 0
s. We've gradually increased them to 90
with the kaizen approach, gradual work.
What if we do a similar measurement in CI for strict types?
parameters:
type_coverage:
return: 90
param: 90
property: 90
+ declare: 5
The new declare
parameter will ensure that at least 5 % of your files use declare(strict_types=1)
. If it's below 5 %, PHPStan will tell us and fail in CI. This has been available since version 0.2.7.
Similar to increasing the type coverage of property, return, and param, the declare feature is done best in small steps:
parameters:
type_coverage:
return: 90
param: 90
property: 90
- declare: 5
+ declare: 7
PHPStan says we should increase our declare(strict_types=1)
usage from 5 % to 7 %. Let's add it to a bunch of files:
<?php
+declare(strict_types=1);
final readonly class Price
{
public function __construct(private int $price, private int $decimalCount)
{
// ...
}
}
If your project has 1000 files, this work requires to:
That might be quite a lot of tedious work, right?
We apply this technique during PHP project upgrades. After a few manual rounds, we felt frustrated and made a Rector rule work for us. This rule is now being tested on the dev branch and will be released sometime next week.
use Rector\TypeDeclaration\Rector\StmtsAwareInterface\IncreaseDeclareStrictTypesRector;
return RectorConfig::configure()
->withConfiguredRule(IncreaseDeclareStrictTypesRector::class, [
'limit' => 10,
]);
Once you run Rector:
vendor/bin/rector
It will find 10 files that lack the declare(strict_types=1)
and add it for you only in the places where it is missing.
Then push, create pull-request, see what fails on CI, fix type errors, and merge.
With this PHPStan and Rector combo, you'll quickly increase your type coverage and safety against typecasting:
parameters:
type_coverage:
return: 99
param: 99
property: 99
declare: 99
Happy coding!
Do you learn from my contents or use open-souce packages like Rector every day?
Consider supporting it on GitHub Sponsors.
I'd really appreciate it!