パラメータのConditional Types

メリークリスマス！ :tada: PHPStan Advent Calendarも20日目になりましたね！

以前、PHPStan 1.6.0(2022年4月リリース)で条件付き戻り値型(Conditional return types)が使えるという話をしました。

https://www.phper.ninja/entry/2022/04/28/015632

これなんですが、戻り値(`@return`)だけでなく、パラメータ(`@param`)でも使えます。

## 問題設定

本を検索する関数を考えてみましょう。

```php
/**
 * @phpstan-param 'asc'|'desc'|'custom' $sort
 * @phpstan-return list<Book>
 */
function searchBooks(string $word, string $sort, Closure $callback = null): array
{
	return [];
}
```

 * 文字列を渡して本を検索する
 * ソートの種類として`'asc'`、`'desc'`、`'custom'`の3種類を受け付ける
 * `'custom'` のときだけ、クロージャを渡して何かをカスタムできるようにする

そういう関数で考えてみることにします。

:::note
設定がガバいので何をどうカスタムできるのかは決めていません。
まあ、外部の検索エンジンに投げるクエリかなんかでしょう。
:::

| sort＼callback | `Closure` | `null` |
|----------------|-----------|--------|
| `asc`          | エラー    | 有効   |
| `desc`         | エラー    | 有効   |
| `custom`       | 有効      | エラー |

エラーになる組み合わせはPHPUnitなどでチェックできるようにしてもいいのですが、PHPStanの機能だけでも静的にチェックできます。

## 解法

```php
/**
 * @phpstan-param 'asc'|'desc'|'custom' $sort
 * @phpstan-param ($sort is 'custom' ? Closure : null) $callback
 * @phpstan-return list<Book>
 */
function searchBooks(string $word, string $sort, Closure $callback = null): array
{
	return [];
}
```

https://phpstan.org/r/38332d60-8a86-4bee-8354-2f9ba9c84142

![スクリーンショット 2023-12-20 22.43.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4121/c4e711df-7800-78d9-6cd3-1fcf670e9ef5.png)

:::note warn
エラーにすべきパターンできちんとエラーになっています
* `'desc'`に`Closure`を渡している
* `'custom'`に`null`を渡している
:::

もちろん、文字列定数ではなく`enum`を使っても大丈夫です。

```php
enum Sort
{
	case ASC;
	case DESC;
	case CUSTOM;
}

/**
 * @phpstan-param ($sort is Sort::CUSTOM ? Closure : null) $callback
 * @phpstan-return list<Book>
 */
function searchBooks(string $word, Sort $sort, Closure $callback = null): array
{
	return [];
}
```

https://phpstan.org/r/78a769c2-d7ee-4f69-a93e-f8c0dbf561cf

## もうちょっと複雑な例

では、もうちょっと複雑な例を考えてみましょう。

```php
enum BookSearchBy
{
	case AUTHOR;
	case NAME;
	case PUBLISHER;
}

/**
 * @return list<Book>
 */
function search(BookSearchBy $query, array $query): array
{
	return [];
}
```

検索の種類(著者名、書名、出版社)によって異なるパラメータを渡さなければいけないとしましょう。

* `BookSearchBy::AUTHOR` で渡すときは `['type' => 'author', 'author' => $string]`
* `BookSearchBy::NAME` で渡すときは `['type' => 'name', 'name' => $string]`
* `BookSearchBy::PUBLISHER` で渡すときは `['type' => 'publisher', 'publisher' => $string]`

:::note
高レベルな関数としては使いにくい設計ですが、外部のデータベースに問い合わせることにこのような変換を要することは、まああるんじゃないでしょうか。
:::

つまりこうです。

```php
/**
 * @phpstan-param (
 *   $by is BookSearchBy::AUTHOR ? array{type: 'author', author: string} :
 *   $by is BookSearchBy::NAME ? array{type: 'name', name: string} :
 *   $by is BookSearchBy::PUBLISHER ? array{type: 'publisher', publisher: string} :
 *   never) $query
 * @return list<Book>
 */
function search(BookSearchBy $by, array $query): array
{
	return [];
}
```

やってみましょう。

```php
$search_by = filter_var($_GET['search_by']);
$word = filter_var($_GET['word'], options: FILTER_NULL_ON_FAILURE) ?? '';

$books = match ($search_by) {
    'author' => search(BookSearchBy::AUTHOR, [
		'type' => 'author',
		'author' => $word,
	]),
    'name' => search(BookSearchBy::PUBLISHER, [
		'type' => 'name',
		'name' => $word,
	]),
    'publisher' => search(BookSearchBy::NAME, [
		'type' => 'publisher',
		'publisher' => $word,
	]),
	default => throw new Exception(),
};
```

https://phpstan.org/r/d2659e27-afb3-4b5c-bf42-bce8b478e817

このコードではわざと`BookSearchBy::PUBLISHER`と`BookSearchBy::NAME`をとりちがえていますが、きちんと警告が出ているのがわかりますでしょうか。

![スクリーンショット 2023-12-21 0.06.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4121/7a25f408-7888-3fab-d7f3-bde5dbcc8157.png)


https://phpstan.org/r/60f8dbdf-05f1-40ca-9d55-03d7438ad8e2

![スクリーンショット 2023-12-20 23.49.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4121/0b67f3c0-16b6-1b54-06cb-92563aeedf17.png)

きっちりと型がついてエラーも出ていないことがおわかりいただけますでしょうか。

## 現状の課題

将来的にクエリが複雑になることを見越してクエリビルダークラスに分割してみましょうか。

```php
$params = match ($search_by) {
    'author' => [BookSearchBy::AUTHOR, new AuthorQueryBuilder()],
    'name' => [BookSearchBy::PUBLISHER, new NameQueryBuilder()],
    'publisher' => [BookSearchBy::NAME, new PublisherQueryBuilder()],
	default => throw new Exception(),
};

$books = search($params[0], $params[1]->build($word));
```

https://phpstan.org/r/c27b2b30-124a-492d-9e00-820b4a75f529

このコードもわざと間違っているのですが、これは残念なことに検知されていません。

:::note
***俺たちの戦いはこれからだ！***
:::