@Jaquarh convinced me it was way easier than I thought it would be by really testing. His version was the basis for this solution. Thanks, Jaquarh!
I haven't tested with Resources since I don't store them, if someone wants to add an answer with Resources please do.
<?php
function test_type(mixed $value, string $type): bool
{ $foundType = strtolower(gettype($value));
$foundType = ['boolean'=>'bool', 'integer'=>'int', 'double'=>'float'][$foundType] ?? $foundType;
$type = str_replace('?', 'null|', $type);
$typesUnion = explode('|', $type);
$typesIntersection = explode('&', $type);
if ( count($typesIntersection)>1) {
if ( ! is_object($value) ) return false;
foreach($typesIntersection as $subtype) {
if ( ! ($value instanceof $subtype)) return false;
}
return true;
}
foreach($typesUnion as $subtype) {
if ( is_null($value) && strtolower($subtype)=='null' ) return true;
if ( $value === false && strtolower($subtype)=='false' ) return true;
if ( $value === true && strtolower($subtype)=='true' ) return true;
if ( is_callable($value) && strtolower($subtype)=='callable' ) return true;
if ( is_object($value) ) { if ( $value instanceof $subtype) return true; }
elseif ( $foundType == strtolower($subtype) ) return true;
}
return false;
}
enum hello { case world; case earth; case mars; };
echo (int) test_type(false, "int|bool"); // 1
echo (int) test_type(null, "?int"); // 1
echo (int) test_type(null, "int|string"); // 0
echo (int) test_type("hello, world", "?bool"); // 0
echo (int) test_type(hello::world, "hello"); // 1
echo (int) test_type(false, "DateTimeInterface|false"); // 1
echo (int) test_type(true, "DateTimeInterface|false"); // 0
echo (int) test_type(new DateTime, "DateTimeInterface|false"); // 1
echo (int) test_type(new DateTime, "null|int|DateTime|DateTimeImmutable"); // 1
echo (int) test_type(new DateTime, "DateTime&DateTimeInterface"); // 1
echo (int) test_type([1,2,3,4], "Array"); // 1
echo (int) test_type(fn($x) => $x+1, "callable"); // 1