fix(shared): deserialize into null-initialized typed properties

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-07-17 15:51:47 -04:00
parent 27b973f3db
commit f9f824a2f0
+53 -3
View File
@@ -77,7 +77,14 @@ abstract class JsonSerializableObject implements JsonSerializable, JsonDeseriali
if (!empty($this->serializableProperties) && !in_array($key, $this->serializableProperties)) {
continue;
}
// Null means absent: leave the property at its default rather
// than passing null into an object's jsonDeserialize/enum
// coercion or overwriting an initialized collection.
if ($value === null) {
continue;
}
$type = gettype($this->$key);
// Handle JsonDeserializable objects
@@ -101,9 +108,10 @@ abstract class JsonSerializableObject implements JsonSerializable, JsonDeseriali
$enumClass = get_class($this->$key);
$this->$key = $enumClass::from($value);
}
// Handle regular values
// Handle regular values, coercing by declared type when the
// current value is null and carries no instance to key off
else {
$this->$key = $value;
$this->$key = $this->coerceValue($key, $value);
}
}
}
@@ -111,6 +119,48 @@ abstract class JsonSerializableObject implements JsonSerializable, JsonDeseriali
return $this;
}
/**
* Coerces a decoded JSON value to the property's declared type. Needed for
* properties whose current value is null: the instanceof-based branches
* above cannot dispatch without an instance, and assigning the raw string
* to a typed property (DateTime, enum, ...) would raise a TypeError.
*/
private function coerceValue(string $key, mixed $value): mixed {
$type = (new \ReflectionProperty($this, $key))->getType();
if ($type instanceof \ReflectionNamedType) {
$names = [$type->getName()];
} elseif ($type instanceof \ReflectionUnionType) {
$names = array_map(static fn($member) => $member->getName(), $type->getTypes());
} else {
return $value;
}
foreach ($names as $name) {
if (!class_exists($name) && !interface_exists($name) && !enum_exists($name)) {
continue;
}
if (is_string($value) && is_a($name, DateTimeInterface::class, true)) {
return $name === \DateTime::class ? new \DateTime($value) : new \DateTimeImmutable($value);
}
if (is_string($value) && is_a($name, DateTimeZone::class, true)) {
return new DateTimeZone($value);
}
if (is_string($value) && is_a($name, DateInterval::class, true)) {
return $this->toDateInterval($value);
}
if ((is_string($value) || is_int($value)) && is_subclass_of($name, \BackedEnum::class)) {
return $name::from($value);
}
if ((is_array($value) || is_string($value)) && is_subclass_of($name, JsonDeserializable::class)) {
/** @var JsonDeserializable $instance */
$instance = new $name();
return $instance->jsonDeserialize($value);
}
}
return $value;
}
protected function fromDateInterval(DateInterval $interval): string {
$spec = '';