Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
huntervk committed Feb 2, 2024
1 parent e639ce3 commit f438364
Show file tree
Hide file tree
Showing 78 changed files with 1,687 additions and 2,287 deletions.
214 changes: 170 additions & 44 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,75 +7,201 @@ Hamlet Type
[![Coverage Status](https://coveralls.io/repos/github/hamlet-framework/type/badge.svg?branch=master)](https://coveralls.io/github/hamlet-framework/type?branch=master)
![Psalm coverage](https://shepherd.dev/github/hamlet-framework/type/coverage.svg?)

There are few aspects of specifying type of expression in PHP:
## Motivation

The PHP type system is a complicated beast. PHP supports types in three ways: hinting, assertions, and casting.

To unwrap the complexity, it's easiest to start with the basic type `int`. The type hinting support is thorough;
`int` can be used as a type hint for properties, arguments, return types, and constants. The assertion through type
hints is both built-in during runtime and optional through static analyzers. The type assertion is done through
the `is_int` function, which returns `true` if the value is an `int` and `false` otherwise. The type casting is done
through the `(int)` cast, which returns an int if the value is an int and produces a warning otherwise. The casting is
often implicit, see for example `5 + '6apples'` returning an `int(11)`. From that point of view, the support for `int`
is complete.

The second, somewhat more complex type is a literal type, like `null` or `true` or `false`. Type hinting is supported.
The assertion is done through the `===` operator. The conversion is missing but the semantics is pretty clear.

> It's a fine distinction here, but the equivalent of `if (is_int($x))` for integers is `if ($x === true)` for `true`,
> and not `if ($x == true)` or `if ($x)`. The latter two expressions contain implicit casting, which changes the type
> of the expression. There is no explicit casting to `null` or `true` or `false` as a library function.
> The equivalent of `(null) $x` would be the following code:
> ```php
> function to_null($x)
> {
> if ($x == null) {
> return $x;
> }
> throw new Warning();
> }
> ```
Yet another case is again slightly more complex. Let's take a look at type `array-key`. It only exists as a type hint
in Psalm and is not explicitly supported by PHP; the only type hinting possible is through PHPDoc. And yet, the type
does "exist". PHP only allows integers and strings as array keys, and if there's a mismatch, tries to _convert_ the
value. The assertion is straightforward: `is_int($key) || is_string($key)`. So, is `array-key` a union of `int`
and `string`? Not quite. The implicit casting done by PHP is somewhat more complex and can be reverse-engineered:
1. The most exact specification of the type (we assume it's in psalm syntax): `array<int,DateTime|null>`
2. The sequence of run time assertions: `assert($records instanceof array<int,DateTime|null>)`
3. The type hint for static analysers (which is _psalm_ at the moment): `(array<int,DateTime|null>) $records`
4. The ability to derive types without string manipulation: `array<int,DateTime|null> || null`
5. The ability to cast when it's safe, i.e. falsey should cast to false, etc.
```php
$a = [$yourKey => 1];
var_dump(array_key_first($a));
```
This library provides the basic building blocks for type specifications. For example, the following expression:
By running this expression, you'll see that there are conversions happening to `$yourKey` that are inconsistent with a
cast "chain" to `int` and, if failed, to `string`:

```php
$type = _map(
_int(),
_union(
_class(DateTime::class),
_null()
)
)
function to_array_key($x) {
try {
return (int) $x;
} catch (Warning $e) {
return (string) $x;
}
}
```

creates an object of type `Type<array<int,DateTime|null>>`.
So, while the assertion is simple, explicit, and obvious, the cast is not.

The next case is the `array` and variations thereof (`list`, `non-empty list`, `array<int>`, etc.). PHP's type hinting
is limited to just `array`, so there's no support for non-emptiness, consecutive integer keys (list layout), key or
element types. All additional variations should be specified through PHPDoc. The assertion by PHP is also limited to
the very basic `is_array`. If you want to extend the assertion to check additional restrictions on the type, you'd need
to write your own custom function like:

Asserting at run time, that the type of `$records` is `array<int,DateTime|null>>`:
```php
$type->assert($records);
function is_non_empty_list($value) {
return is_array($value) && array_is_list($value) && count($value) > 0;
}
```

Cast `$records` to `array<int,DateTime|null>>` and throw an exception when `$records` cannot be cast to `array<int,DateTime|null>>`:
The casting support by PHP is also rather random. There's a wrapping of scalar values and unwrapping of objects,
but there's no support for value casting like `(array<string>) [1, 2, 3]`.

The next case is classes. The type hinting is fully supported, and the assertion is done through `instanceof`. Casting
is non-existent. Interestingly enough, the conversion from an associative array to an object of a specific class is one
of the most common operations in PHP, and yet there's no built-in support for it. There are a few libraries that do
that, including "json-mapping", but the semantics of such a cast are bespoke.

The next case is union types. There's hinting support by PHP for the union itself. The completeness of the hinting
support for union types crucially depends on the support for the types that are being unioned. For example, `int|null`
is as complete as it gets, and `array<int>|array<string>` is only supported through PHPDoc. The same applies to
assertions. The `is_int($a) || is_null($a)` is good enough in the first case, but there's no built-in equivalent in PHP
to assert the type `array<int>|array<string>`. There's also no casting.

The lack of casting support for union types is not surprising. If we treat `int|string` as the same type as
`string|int`, and we should, then there's no simple way to reconcile `(int|string) $a === (string|int) $a` for all `$a`.
The proper way of handling casting to union types should likely be:

```php
return $type->cast($records);
if (!$this->match($value)) {
throw new CastException();
}
return $value;
```

Combine type with other types, for example, making it nullable `array<int,DateTime|null>>|null`:
The last case is generics and callables. There's no support for generics in PHP as of now, and yet they are implicitly
used by a lot of tools these days. The hinting support may come one day. The assertion support is doubtful beyond
some special cases like `array`. For example, it's not possible to assert beforehand that an endless generator is of
type `iterable<int>`.

```php
_union($type, _null())
function ints(): iterable {
$i = 0;
while (true) {
yield $i++;
if (cosmic_ray()) {
yield "haha";
}
}
}
```

## Object like arrays
The same applies to callables. The hinting support is there, but assertions and casting are likely not possible.

Object like arrays require more leg work. For example the type `array{id:int,name:string,valid?:bool}`
corresponds to this construct:
## Library support for hinting, assertions and casting

```php
/** @var Type<array{id:int,name:string,valid?:bool}> */
$type = _object_like([
'id' => _int(),
'name' => _string(),
'valid?' => _bool()
]);
```
The library provides a consistent support for hinting, assertions and casting as much as possible.

Quite a mouthful. Also, note the required `@var` as psalm currently have no support for dependent types.
For type hints there are two options.

If you want to assert types matching your PHPDoc you can use the type parser (WiP):
Firstly, you can construct types by using type constructors:

```php
/** @var Type<array{id:int}> */
$type = Type::of('array{id:int}');
$type = _map(_int(), _union(_class(DateTime::class), _null()));
```

Secondly, for libraries and metaprogramming you can parse PHPDoc or type expression by calling `type_of`.

```php
$type = type_of('array<int,DateTime|null>');
```

The support for assertions is provided by the `match` and `assert` methods. The first method returns `true` if the value
confirms to type specification (as much as it's possible to infer at run time). The second method (a convenience one) works as a pass-through
that throws an assertion exception if there's no match.

assert($type->matches($record));
```php
$a = _non_empty_list(_int())->assert($properties['a']);
```

This will add assertion on the type of `$a` being `non-empty-list<int>`, and tell Psalm that it's the type of `$a` now.

The support for casts is ... @todo add resolver explanation.

## Invariant

The motivation of this library to provide a consistent and complete support for PHP types. Given a type hint, for
example `array<int,string>`:

- The `match` method should return `true` only for an `array` where every key `is_int` and every value `is_string`.
The semantics of `match` is as consistent with PHP and Psalm documentation as possible. No casting is allowed.
- The `assert` method should throw an assertion exception if the type does not `match` the type hint.
- The `cast` method consistently extends the semantics of `(int)`, `(string)` and other PHP built in casts for the rest
of the types. The `cast` method should throw a cast exception if the type cannot be cast to the target type.

The invariants of the type algebra should be:

$type->match($type->cast($value)) === true
// or an CastException is thrown

if ($type->match($value)) {
$value == $type->cast($value); (Note that for most types, it's `===`, except array-key)
}
// and no CastException can be thrown

## Type Overview

Types supported:

| Type | Function |
+------------------------+----------------------------+
| mixed | _mixed() |
| int | _int() |
| float | _float() |
| string | _string() |
| non-empty-string | _non_empty_string() |
| numeric-string | _numeric_string() |
| bool | _bool() |
| numeric | _numeric() |
| array_key | _array_key() |
| null | _null() |
| 'a'|1|false | _literal('a', 1, false) |
| object | _object() |
| resource | _resource() |
| UserType | _class(UserType::class) |
| int|float | _union(_int(), _float()) |
| list{int,string} | _tuple(_int(), _string()) |
| array<string> | _array(_string()) |
| list<id> | _list(_id()) |
| non-empty-array<int> | _non_empty_array(_int()) |
| non-empty-list<string> | _non_empty_list(_string()) |
| array<int,string> | _map(_int(), _string()) |

## Background

- Move completely to PHPStan parsed including docblock
- Add union and intersection types
- Move completely to PHPStan parser, including docblock
- Add intersection types
- Support for enums
- Support for non-empty-* types
- Support for tuples as int `array{int,string}`
- Support for iterable|self|static|class-string
- Better support for callables
- Add PHPStan to QA pipeline
- Support for the rest of scalar types: https://psalm.dev/docs/annotating_code/type_syntax/scalar_types/
- Support for iterable|self|static|class-string|interface-string
- Maybe, support for callables and object-like-arrays, in terms of parsing, but not algebra
23 changes: 0 additions & 23 deletions psalm-cases/00-random-casts.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@

require_once __DIR__ . '/../vendor/autoload.php';

use Hamlet\Type\Type;
use function Hamlet\Type\_bool;
use function Hamlet\Type\_callable;
use function Hamlet\Type\_class;
use function Hamlet\Type\_float;
use function Hamlet\Type\_int;
Expand All @@ -14,7 +12,6 @@
use function Hamlet\Type\_mixed;
use function Hamlet\Type\_null;
use function Hamlet\Type\_object;
use function Hamlet\Type\_object_like;
use function Hamlet\Type\_string;
use function Hamlet\Type\_union;

Expand All @@ -25,13 +22,6 @@ public function bool(): bool
return _bool()->cast(true);
}

public function callable(): callable
{
return _callable()->cast(function (): int {
return 1;
});
}

public function stdClass(): stdClass
{
return _class(stdClass::class)->cast(new stdClass());
Expand Down Expand Up @@ -89,19 +79,6 @@ public function object(): object
return _object()->cast(new stdClass());
}

/**
* @return array{id:int}
*/
public function object_like(): array
{
/** @var Type<array{id:int}> $type */
$type = _object_like([
'id' => _int()
]);

return $type->cast(['id' => 1]);
}

public function string(): string
{
return _string()->cast('hello');
Expand Down
18 changes: 0 additions & 18 deletions psalm-cases/02-object-like-array.php

This file was deleted.

1 change: 0 additions & 1 deletion psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
<directory name="src" />
<file name="psalm-cases/00-random-casts.php"/>
<file name="psalm-cases/01-maps-narrowing-array-keys.php"/>
<file name="psalm-cases/02-object-like-array.php"/>
<ignoreFiles>
<directory name="vendor" />
</ignoreFiles>
Expand Down
14 changes: 0 additions & 14 deletions src/ArrayKeyType.php

This file was deleted.

29 changes: 0 additions & 29 deletions src/BoolType.php

This file was deleted.

Loading

0 comments on commit f438364

Please sign in to comment.