Enhancing types in PHP
Matthias Noback has published an article questioning whether DateTimeImmutable
can be considered a primitive type. Matthias concludes that a DateTimeImmutable
is not a primitive type.
Primitive type
We can probably agree that the following types
-
array
-
bool
-
float
-
int
-
object
-
string
are all primitive types.
Simple type
We can probably also agree that a DateTimeImmutable
is better than a string
, a non-empty string
, and better than a string that matches a certain regular expression. So if you are dealing with a DateTimeImmutable
, you can be sure it holds a valid date.
If you look at all the usages of a DateTimeImmutable
in a code base, the only thing the corresponding values have in common is that they are valid dates. But are these values reasonable in a specific context? For example, do they refer to a date in the next quarter or a date of birth?
I am arguing that, at best, a DateTimeImmutable
is a sophisticated primitive type, perhaps something we can call a simple type.
Ted M. Young calls these types unconstrained types.
This is why I include all of the Collection classes under the "Primitive Obsession" umbrella. They're "primitives", too, i.e., unconstrained types, that need encapsulation.
— Ted M. Young #BLM #ProBodyAutonomy (@jitterted) September 2, 2022
In IntelliJ IDEA, it's a few automated steps to refactor to a new class in case you waited too long. https://t.co/yEs7FWKokP
Also see the slides for Ted's talk Stop Obsessing About Primitives.
Proper type
In my opinion, a proper type describes an object
- that carries meaning (it's a date of birth)
- that enforces constraints for the primitive(s) it encapsulates (it's today or in the past)
- that encapsulates operations when necessary, for example, comparison with other instances (it refers to the same day)
- that a developer can trace across a code base (all of these instances refer to dates of birth)
A DateTimeImmutable
- does not carry meaning beyond referring to a date
- can not enforce whether the encapsulated value is in the future or the past
- does not encapsulate necessary operations, for example, comparison with other instances without knowing which components need to be considered
- can not be reliably traced across a code base
Without context, a DateTimeImmutable
object does not satisfy the requirements of a proper type.
From primitive to proper type
Replacing primitive types with simple types certainly enhances legacy code bases. However, it is only a step in the right direction while figuring out requirements.
For example, assume a legacy code base has a User
.
<?php
declare(strict_types=1);
class User
{
private string $dateOfBirth;
// ...
public function __construct(string $dateOfBirth)
{
$this->dateOfBirth = $dateOfBirth;
}
public function dateOfBirth(): string
{
return $this->dateOfBirth;
}
// ...
}
As a first step, we can replace the primitive string with a DateTimeImmutable
.
<?php
declare(strict_types=1);
class User
{
private \DateTimeImmutable $dateOfBirth;
// ...
public function __construct(DateTimeImmutable $dateOfBirth)
{
$this->dateOfBirth = $dateOfBirth;
}
public function dateOfBirth(): DateTimeImmutable
{
return $this->dateOfBirth;
}
// ...
}
As a second step, we can introduce a simple DateOfBirth
type to replace the simple DateTimeImmutable
.
<?php
declare(strict_types=1);
final class DateOfBirth
{
private DateTimeImmutable $value;
private function __construct(DateTimeImmutable $value)
{
$this->value = $value;
}
public static function fromDateTimeImmutable(DateTimeImmutable $value): self
{
return new self($value);
}
public function toString(): string
{
return $this->value;
}
}
The simple DateOfBirth
type
- carries meaning (it's a date of birth)
- encapsulates necessary operations, for example, comparison with other instances (it refers to the same day)
- can be traced by a developer across a code base (all of these instances refer to dates of birth)
The simple DateOfBirth
type does not yet enforce constraints for the simple type it encapsulates.
However, the User
class and corresponding call sites can now already start using the DateOfBirth
type.
<?php
declare(strict_types=1);
class User
{
private DateOfBirth $dateOfBirth;
// ...
public function __construct(DateOfBirth $dateOfBirth)
{
$this->dateOfBirth = $dateOfBirth;
}
public function name(): DateOfBirth
{
return $this->dateOfBirth;
}
// ...
}
As a third step, when requirements are clear, we can turn the simple DateOfBirth
into a proper type that also enforces constraints for the simple DateTimeImmutable
it encapsulates.
<?php
declare(strict_types=1);
final class DateOfBirth
{
// ...
/**
* @throws InvalidDateOfBirth
*/
public static function fromDateTimeImmutable(
DateTimeImmutable $value,
DateTimeImmutable $threshold
): self {
if ($threshold->format('Y-m-d') < $value->format('Y-m-d')) {
throw InvalidDateOfBirth::with($value);
}
return new self($value);
}
// ...
}
The proper DateOfBirth
type now
- carries meaning (it's a date of birth)
- enforces constraints for the primitive(s) it encapsulates (it's today or in the past)
- encapsulates comparison (it refers to the same day)
- can be traced by a developer across a code base (all of these instances refer to dates of birth)
What do you think? Are you better off with primitive, simple, or proper types?