Zenn
⌨️

PHPStan型付けマニュアル

2025/03/30に公開
6

こんにちは! 楽しくPHPStanを使っていますか? それともPHPStanに使われていますか?

PHPStanは非常に賢く、容易にPHPコードの 「嫌な気配」 を検知してくれます。ただ、PHPStanが指摘することが常に正しいなどといったことはなく、いつでもプログラムが動作しているという事実が前提で、静的解析はその影を追っているに過ぎません。

PHPStanがどのようなメカニズムで型を付けているのか理解できていないと、自我を失って機械に言われるがままに意図しないコードを書かされる人形になってしまいます。本稿ではPHPStanを自律的に使うために前提となる知識を紹介します。

ここからプログラミング言語と型、そしてPHPについての議論を始めたいのですが、「PHP」という名前はプログラミング言語の名前であり、PHP言語で書かれたソースコードを実行するプログラム名でもあります。ややこしいのですが、単にPHPと書くと言語名、phpと書くとPHPを実行するソフトウェアを指すことにしましょう。

また、言語処理系とはプログラミング言語で書かれたソースコードを解析して実行したり、実行可能なプログラムファイルに変換するソフトウェアの総称です。phpのようにソースコードを直接起動する言語処理系は特にインタプリタ(interpreter)と呼ばれます。一方でC言語などはコンパイラ(compiler)と呼ばれるソフトウェアでソースコードから実行可能プログラムを生成して実行することが一般的です。

型システムって何?

PHPは振る舞いサブタイプ(behavioral subtyping)と公称型システム(nominal type system)に基づくプログラミング言語です。

前者のアイディアはいわゆるリスコフの置換原則(Liskov substitution principle)と関連する非常に興味深いトピックなのですが、PHPStanの型システムを理解する上では遠回りになるので、本稿では説明しません。

ここで型システムというキーワードが出てきました。この用語について、Benjamin C. Pierceは以下のように説明しています。

「型システム」とは何か、プログラミング言語の設計者や開発者たちが日常的に口にする際の意味合いをすべて反映し、なおかつ十分に意味のある具体的な定義を与えるのが難しいのは、複数の大きなコミュニティが共有する多くの用語と同様である。しかし、次のような定義はできよう。

型システムとは、プログラムの各部分を、それが計算する値の種類に沿って分類することにより、プログラムがある種の振る舞いを起こさないことを保証する、計算量的に扱いやすい構文的手法である。

(Benjamin C. Pierce著、住井英二郎監訳、「型システム入門 プログラミング言語と型の理論」オーム社、p1より引用)

この用語は必ずしも形式的な定義があるわけではなく、プログラミングという実用の世界でニュアンスが共有されつつ、やや曖昧に使われてきたことが伺えます。「プログラムがある種の振る舞いを起こさないことを保証」するということは、検査対象が「どう振る舞うか」把握するということでもあります。

もう少し簡単に「データ型に注目して、プログラムの一部分がどう振る舞うかを捉える一連のルール(システム)」としてみましょう。たとえば「1 + 1」というPHPコード断片があります。これを実行すれば 2 という値が得られます。

次に「$a + $b」ではどうでしょうか。これは複雑で、変数に代入された値の組み合わせによって複数の場合が考えられます。

  • $a$bの両方がnumeric ⇒ 結果は数値型intまたはfloat
  • $a$bの両方が配列 ⇒ 結果は配列型intまたはfloat
  • $a$bのどちらかがnumericでも配列でもない ⇒ エラー
  • $a$bの両辺にnumericと配列が混ざっている ⇒ エラー

ここでnumericと呼んでいるのは、整数・浮動小数点数・数値文字列・BcMath\Number型のオブジェクトなど「数値らしく」振る舞う値です。PHPはプログラムを実行するタイミングで、可能な限り柔軟に処理する戦略をとっています。実行するタイミングで、ということは言い換えると「実行前にPHPが保証してくれることは限りなく少ない」ということになります。

1 + 1」や「a + b」のような文字の並びは多くのプログラミング言語で有効なコードの断片ですが、プログラムの実行前処理の一部で型を検査し「整数以外の型を**+で加算したらエラー」「両辺の型が一致しなければエラー」といった言語処理系(コンパイラ)もあります。プログラムを実行する前段階で型を調べることを静的型検査**と呼びます。

一方でphpはソースコードに「正しくない」箇所が含まれていても構わずにプログラムを起動し、すべてのプログラムの一部に対して、操作対象となる値が正常に処理できる型かをチェックしながら実行することを動的型検査と呼びます。

静的型検査によって型システムによってプログラムが型付けされたならばプログラムの実行時に型エラーが発生しないことが保証できるという性質を型システムの健全性(soundness)と呼びます。また、静的型検査によって型付けされた上で実行することを想定したプログラミング言語は静的型付き言語(statically typed programming language)と呼ばれます。

これに対して静的型検査を前提とせず、型付けされない状態で実行されることが想定されるプログラミング言語は動的言語(dynamic language)と呼ばれます。型が付いていることを前提としないので型なし言語とも呼ばれます。「静的」と「動的」は対照的に見える概念ですが、全てのプログラミング言語がはっきりと色分けできるわけではありません。静的型付きの言語でも実行時処理の柔軟性のために健全性を低下させて動的型検査をサポートする言語もあります。

PHPは動的言語として分類されますが、phpは関数のパラメータや戻り値の型宣言を実行時に保証するため、局所的に型付けされた言語だとみなすこともできるでしょう。本稿が主な関心とするPHPStan(PHP Static Analysis Tool)はコンパイラに代わって実行前にPHPプログラムに型を付け、型エラーを検出し、PHPの健全性を引き上げることができるツールだと言えます。

筆者は動的言語でのプログラミングが大好きです。動的言語には無限の可能性があります。静的型付き言語がコンパイルプロセスで最低限のデータ型の整合性を保証してくれるのに対して、型なしのコードには何の保証もありません。すなわち型がない関数には引数に何を渡せばいいのかわかりませんし、どんな値が返ってくるのかわかったものではありません

そんな無限に乱雑になっていく可能性を秘めたコードベースを、人間が扱えるように制御するための強力な武器がPHPStanです。

PHPの組み込み型

ここまでPHP(などの動的言語)は型なしだという話をしてきましたが、「PHPにもstringとかintとか、型はあるじゃないか」と反論されるかもしれません。その通りです。ここまでの型なしというのは「変数の状態が絞り込まれていない」くらいの意味で考えてください。実際にはPHPにもさまざまなデータ型があります。(本稿では触れませんが、データ型が1種類しかない言語もあります)

本文冒頭で述べた通り、PHPの型システムは公称型システム(nominal type system)と呼ばれます。型宣言されたパラメータ・戻り値・プロパティには、宣言した型の値(インスタンス)のみを受け入れます。phpは静的型検査を行なわないためエラーは実行時に遅延します。型宣言に対して受け入れできない関数呼び出しがコードに書かれていても、実際にそのコードが実行するまでエラーがわかりません。ただし、戻り値がvoidで宣言された関数・メソッド内にreturn 1;return null;のような値を返す記述があったり、never宣言された関数・メソッドにreturn;があると、コードがロードされたタイミングでエラーが発生します。

スカラー型の型名はintboolのような短い型名が標準で、integerbooleanなどの別名は存在しますが、型宣言に利用できません。Pythonと異なり文字列はstringであって、strという別名は存在しないので気をつけてください。

2024年にリリースされたPHP 8.4現在では、ユーザーが任意の型を定義する言語機能は提供されていません。そのため、たとえばFoo|Bar|Buzのような複合的な型も、型宣言する箇所すべてに同じ記述をそのまま書く必要があります。


ところで、phpはC言語で実装されています。本稿では深入りしませんが、PHPで「」として見えているものの実体は、zvalと呼ばれるデータ構造です。C言語レベルでは構造体(struct)と共用体(union)の入れ子で表現されています。C言語の共用体(union)とPHPのユニオン型は使い方は異なりますが、「複数の型のいずれかを受け入れる型」という概念で共通するものです。ただし、Cの共用体は複数の型を受け入れることはできても、実際に保持された値がどの型かを区別することはできません。どの型かを区別するには、型タグと呼ばれる情報をセットで保持して、プログラマーの責任で区別する必要があります。

PHPでは実行時に値の型やクラス名をis_string()is_int()のような関数、instanceof演算子$obj instanceof DateTime)、$obj::class(マジカル定数)、get_debug_type()関数などで取得・識別できます。もちろんそれは、インタプリタであるphpが値としてメモリ上に常に型タグを組み合わせて保持する実装になっているためです。

型宣言と型モード

コード冒頭にdeclare(strict\_types=1);と宣言することで、厳密な型モードがファイル単位で有効化されます。型宣言されたユーザー定義関数は、モードによって振る舞いが変わります。

<?php

function f(int $v): int
{
    return $v;
}

// 数値文字列はキャストして関数に引き渡される
var_dump(f('1.0')); // => int(1)
// bool値もキャストされてしまう
var_dump(f(true)); // => int(1)
// 数値ではない異物が入っているとエラー
var_dump(f('1a')); // => TypeError

厳密モード宣言をすると、これらは全てTypeErrorが発生します。静的解析していないコードベースでやみくもに型宣言を追加したり、既に動いているコードベースでやみくもにdeclare(strict_types=1);を追加して、f((int)$value)のようにキャストに書き換えるのは等価ではなく既存の振る舞いを破壊するため、あまり得策ではないでしょう。

「型が付く」とはどういうことか

誤解を恐れずに言えば、言語処理系がデータ型の種類を識別することだと言えましょう。OCamlという関数型プログラミング言語は型宣言をコード上に書かずとも、型推論によって型が付くことが特徴です。

# let plus1 n = n + 1
  let f m = plus1 m  ;;
val plus1 : int -> int = <fun>
val f : int -> int = <fun>

この表記は若干わかりにくいですが、# letから始まる部分から ;; までが関数定義のコードで、太字の**val**から始まる行がOCamlコンパイラがソースコードを解析して自動で付けた型が画面に出力されたものです。

このコードをまったく同じ意味のPHPコードに書き直すと以下のようになります。

$plus1 = fn(int $n): int => $n + 1;
$f = fn(int $m): int => $plus1($m);

OCamlは関数定義にまったく型を記述しなくても、型推論によって静的な型がつきます。JavaやC#など多くの静的型付き言語において、「型を付ける」とは関数やメソッドの入出力の型をユーザがプログラマがソースコードに明示的に書くことを指します。

プログラマが明示的に型を記述しなくても静的な型が付くOCamlのような性質を型推論の完全性(completeness)といいます。残念ながら、普及している多くの言語はさまざまな理由から型推論の完全性はありません。かつてはJavaやC#などにおいて、メソッドだけでなくローカル変数すべてにプログラマが型を明示する必要がありましたが、2000年代から徐々にローカル変数に限って型推論を行なう言語が普及し、現代となっては一般的ともいえる状況になりました。

詳細は後述しますが、PHPStanは関数・メソッド・プロパティなどに適切な型を明示、あるいはDynamicReturnType拡張によって型を与え、ローカル変数は型推論のみで適切な型がつくようにすることが理想です。また、OCamlとPHPStanは型推論の戦略がまったく異なり、上記の関数に型を明示しなかった場合に推論される型はまったく異なる結果になります。

型安全性(type safety)という考えにも触れておきましょう。これは先ほど言及した健全性(soundness)を言い替えただけの言葉です。健全性の定義は「プログラムが型付けされたならば」「実行時に型エラーが発生しない」ということでした。phpは実行前に型を付けませんが、インタプリタが実行時に関数呼び出しやプロパティ代入の度に動的型検査を行い型に違反する操作が行なわれた時点でプログラムをクラッシュさせる(TypeErrorを送出する)ことで、実行中のプログラムが正しく型付けされたことを保証しながら処理を進めます。つまり、「型宣言した上で、プログラムを一通り動かして意図しないクラッシュが起こらなければ、たぶん本番でも期待通り安全に動くだろう」ということです。function f(string $s): void { printf("dump: %s\n", $s); } という関数はf()関数が正しく呼び出されさえすれば、実行時に型が理由のエラーが発生する余地がないと言えます。

phpのような動的言語ランタイムが提供する「型安全」にはいくつもの穴があります。

  • プログラムを実行してみなければ型に齟齬があるかどうかわからない
    • リリース前のユニットテスト・システムテストの網羅性(カバレッジ)次第
  • 詳細な型は検査されない
    • array内部の構造、callableの引数・戻り値など
  • 型宣言をパラメータ化して変数のように扱うことはできない
    • 型宣言には「広い」型を書かざるを得ず、適切に型が絞り込まれない

PHPマニュアルに「関数の呼び出し結果はint|string|arrayです!」と言われても、その結果をstringを受け付ける別の関数に渡すのは、システムが保証する意味での「安全」とは言えません。たとえば以下のようなコードを考えましょう。

function g(array $a, int $n): void
{
    printf("dump: %s\n", array_rand($a, $n));
}

関数に適切に型宣言をしているにも関わらず、なぜ「安全」に呼び出せないのか、PHPマニュアルの https://www.php.net/array_rand を読むとわかるでしょうか。PHPStanはコードを検査してエラーの可能性を探ることができますが、導入するだけで直ちにコードベースの問題が解決する銀の弾丸でもありません。ツールは人間がコードを改善する取り組みを助ける道具に過ぎません。

PHPStanはどのように型を得るのか

PHPの基本的な型の振る舞いについて整理したところで、PHPStanがどのように型を検査するのか学びましょう。PHPStanのソースコードはGitHubの https://github.com/phpstan/phpstan-src にあるので、できればgit cloneしてPhpStormなどで追うとよいでしょう。

PHPStanが型をつけるにあたって基本的なAPIはPHPStan\Analyser\Scope::getType(Expr $expr): Typeメソッドです。これは検査中の変数スコープの状態を持ったオブジェクトです。PhpParser\Node\ExprはPHP-Parserの構文木の「式」を表すノードです。Scopeはインターフェイスなので、実装を追いたい方はPHPStan\Analyser\MutatingScope具象クラスを読んでください。MutatingScope::getType()からMutatingScope::resolveType()に処理が引き継がれ、ここで変数の解決などが行われることもわかります。演算子式の結果などはInitializerExprTypeResolverで処理されます。興味深いのは、このクラスは一種のインタプリタのような仕事をしていることです。また、MutatingScope::resolveType()からは関数呼び出しごとにDynamicReturnType拡張が呼び出されます。

とても重要なことは、PHPStanこの一連の処理によって全ての式、つまりソースコード中の値を持つあらゆる一部分に型をつけるということです。「あらゆる一部分」とはどういうことでしょうか。以下のようなPHPコードを考えてみます。

$a = 1 + 2;
$b = 3;
echo sprintf("%d", $a + $b * 10);

このコードのうち、値を持つコードの一部分は「1」「2」「1 + 2」「$a = 1 + 2」「3」「$b = 3」「"%d"」「$a」「$b」「10」「$b * 10」「$a + $b * 10」「sprintf("%d", $a + $b * 10)」の13パターンです。これらの組み合わせは、いずれも最終的な計算結果を求めるための過程の一部分としてvar_dump($a + $b * 10);のようにコピーしても(変数の更新が起こらなければ)同じ結果が得られます

これまで説明なしに型推論(type inference)という用語を用いてきましたが、PHPStanが行なっているこの振る舞いこそが型推論に他なりません。つまり、sprintf("%d", $a + $b * 10)という関数呼び出し結果の型を得るために、PHPStanは引数それぞれの型も再帰的に調べます。式の結果を求めることを評価する(evaluate)といいます。評価という用語はプログラムの実行によって式から値を求める場合にも使いますが、この文脈では型推論の過程で型を調べることも評価と呼んでいます。

改めて、「$a + $b * 10」という式の型を調べるためには $a$b の代入式を再帰的に評価し、$a に代入される値は「1 + 2」を評価し… ということを、必要なあらゆる組み合わせに対して行ないます。

重要なこととして、この式においては演算子の優先順位により「$a + $b」が処理されることはありません。PHPの演算子式の評価においては「+」よりも「*」を優先して結合するからです。また、PHPの文は値を返さない構文要素として式とは区別されます。echo文は値を返さないため、型評価の対象にはなりません。

たった3行のコードを型推論するために13回の評価が行なわれるのですから、もっと複雑な式や多くの行を持つファイル、さらには数多くのファイルが相互作用するプロジェクトでは途方もない回数の型の評価が行なわれることは想像に難くないでしょう。これら全ての組み合わせを現実的な時間で型解析できるように最適化されていることはPHPStanの非常に優れた特徴だと言えます。

PHPStanがある式をどのように評価して型付けしているかは\PHPStan\dumpType();関数でチェックできます。

\PHPStan\dumpType(sprintf("%d", $a + $b * 10));

上記コードをPHPStan導入済みの既存プロジェクトに入力してチェックしてみてもいいですが、シンプルなコードはWebブラウザからPHPStan Playgroundに入力するのが簡単です。このURLはPHPStanのバグ報告や、期待通りに型がつかないことをオンラインで相談する際にも非常に重要です。 https://phpstan.org/r/89d5140a-013a-4c6c-a39f-afffe598fff0にアクセスすると、PHPStanが上記の全ての組み合わせ全てに型を付けることを確認できます。

必要なタイミングでdumpType()を能動的に使えるかどうかが、PHPStanの初心者と中級者を分ける壁だと言えるでしょう。

PHPStanの内部の型オブジェクト

ここまで述べた通りPHPStanはScope::getType()メソッドによって、式の評価結果の型を得ることができます。PHPStanは定数として評価される型は 1 + 2 という式に対して、IntegerTypeではなく、ConstantIntegerType(3)という型を返します。PHPStanの型システムは可能な限り定数型を維持します。つまりTypeScriptで常にas constとして評価する振る舞いに似ています。

PHPStanが取り扱う「型」の実体は https://github.com/phpstan/phpstan-src/tree/2.1.x/src/Type で定義されているinterface PHPStan\Type\Typeを実装したクラスのインスタンスです。PHP組み込みのスカラー型に対応したクラスのほか、具体的な定数値を持つinterface ConstantScalarType、型にIntersectionTypeで組み合わせることで属性を付与するinterface AccessoryTypeなどがあります。以下に一部の型を抜粋します。

  • interface PHPStan\Type\Type
    • PHPStan\Type\BooleanType
      • PHPStan\Type\ConstantBooleanType
    • PHPStan\Type\IntegerType
      • PHPStan\Type\IntegerRangeType
      • PHPStan\Type\Constant\ConstantIntegerType
    • PHPStan\Type\ArrayType
      • PHPStan\Type\Constant\ConstantArrayType
    • PHPStan\Type\StringType
      • PHPStan\Type\Constant\ConstantStringType
    • interface PHPStan\Type\Accessory\AccessoryType
      • PHPStan\Type\Accessory\NonEmptyArrayType
      • PHPStan\Type\Accessory\AccessoryNonEmptyStringType
      • PHPStan\Type\Accessory\AccessoryNumericStringType
      • PHPStan\Type\Accessory\AccessoryArrayListType
      • PHPStan\Type\Accessory\OversizedArrayType
      • PHPStan\Type\Accessory\AccessoryUppercaseStringType
    • PHPStan\Type\ObjectType
      • PHPStan\Type\EnumCaseObjectType
      • PHPStan\Type\TemplateObjectType
      • PHPStan\Type\GenericObjectType
    • interface PHPStan\Type\CompoundType
      • PHPStan\Type\UnionType
        • PHPStan\Type\BenevolentUnionType
      • PHPStan\Type\IntersectionType

ユニオン型はFoo|Bar|Buzのように複数の型を持ち、いずれかの型のサブタイプの値を受理します。インターセクションは逆で、Foo&Bar&Buzのような複数の型すべてのサブタイプである必要があります。

これらの型オブジェクトのクラスはPHPDocで用いる型と一対一では対応しません。たとえば、non-empty-stringStringTypeAccessoryNonEmptyStringTypeのインターセクション、non-empty-listAccessoryArrayListTypeNonEmptyArrayTypeArrayTypeとのインターセクションです。

PHPDocによる型

PHPDocではPHPの組み込み型では表現できないものを含む、より詳細な型が記述できます。

  • PHPの型宣言がサポートする型表記
    • string, int, bool, float, array, object, bool(true, false), null, callable, iterableのような型名
    • void, neverのような戻り値のみに許可された型名
    • クラス名およびインターフェイス名
    • A|B|C(ユニオン型), A&B&C(インターセクション型), (A&B)|D(DNF/選言標準形 = ユニオンとインターセクションのネスト)
    • ?A(null許容型、A|nullと同じだが、複雑な式の一部には書けない)
  • PHPがサポートしない型表記
    • string, integer, boolean, doubleのような型エイリアス
    • resourceのような型宣言にサポートされない型名
    • 123, int<max, -1>(負整数), int<0, 6>(0〜6の範囲)のような整数範囲型
    • int<1, max>(1以上の整数), int<max, -1>(負整数), int<0, 6>(0〜6の範囲)のような整数範囲型
    • その他の型キーワード(non-empty-string, positive-int, scalar, array-key, pure-closure, open-resourceなど)
    • array<array-key, string>, non-empty-list<DateTime>などのジェネリクス型
    • array{word:string, order:'asc'|'desc', month: int<1, 12>}などのarray-shapesおよびobject-shapes
    • ($param is T ? TThen : TElse) のような条件付き型
    • Closure(int, int): int|float のようなcallable式

そのほか、最新のPHPStanでどのような型がサポートされているのか、型の実体はどのような型オブジェクトになっているかは、読者自身で https://github.com/phpstan/phpstan-src/blob/2.1.x/src/PhpDoc/TypeNodeResolver.php にあるソースコードを実際に読んで自身で確認するのがよいでしょう。TypeNodeResolver::resove()は単独で成立する型が、TypeNodeResolver:: resolveGenericTypeNode()型パラメータを持った array<string, DateTime> のような型を解決します。

また、PHPStanは標準ではTypeScriptのような豊富なユーティリティ型を提供しませんが、TypeNodeResolverExtension拡張を実装すれば実現できます。……ところで私は戻り値の型を毎回書くのが面倒なので、@return autoのように型推論によって関数・メソッドの戻り値を決定できれば簡単かつ事故を減らせることもあるのではないかと頭によぎるのですが、これは実現できないのでしょうか。この問いに答えを出すことはさして難しくはないのですが、PHPStanの拡張メカニズムを学ぶ上で有意義かと思うので、読者への課題としましょう。

このような胡乱な問いについては一旦忘れることにして、以下のような矛盾した関数実装に型宣言とPHPDocがあった場合、その戻り値をPHPStanはどう取り扱うのでしょうか。

/**
 * @return int
 */
function f(): string
{
    return [];
}

\PHPStan\dumpType(f());

PHPStanによる関数の型解決

本稿執筆時点のPHPSlan 2.1では次のように解決します。

  1. (PHPStanは戻り値の型推論を行わなず、関数の型を解決する上ではreturn [];は考慮されない)
  2. ReflectionProviderからRelectionFunction(関数のメタデータ)を取得
  3. ParametersAcceptorSelectorから引数と戻り値値の情報を持ったParametersAcceptorを取得
    1. 型宣言されたstringを関数の戻り値の型とする
    2. PHPDocがあり、@returnタグが戻り値と矛盾しなければタグの型で縮小する
  4. 関数またはメソッドに対して登録されたDynamicReturnType拡張があり、null以外が返されるとそれを戻り値の型とする
  5. ParametersAcceptor::getReturnType()が解決した型を戻り値の型とする

これらの処理でやっていることはたしかに多いのですが、実際のところ関数の戻り値は、宣言された型定義とDynamicReturnType拡張しか見ていません。高度な型システムを持つ言語はコンパイル時間が伸びることが宿命とも言えますが、PHPStanはこの割り切りによって実用的な時間での検査が実現できていると筆者は捉えています。

つまりPHPで実用的に型を付けるには、以下の4択のどれかを状況に応じて選択することになります。

  • a. シンプルな型宣言だけで型が付くようにする
  • b. PHPDocを用いて適切に型を付ける
  • c. PHPStan拡張を実装して実体に即した型を付ける
  • d. 戻り値として型を付けることを諦めて、利用側で型を絞り込む

関数・メソッドの呼び出し結果に適切な型がついていれば、利用側で得た値をそのまま別の用途に使いやすくなります。自分たちで実装する関数は上から順に理想的で、dは可能な限り避けたいところですが、現実的にはさまざまな理由から利用側で型を絞り込まなければいけないことも多々あります。

PHPDocを活用した関数の型付け

const ASCDESC = ['昇順' => 'ASC', '降順' => 'DESC'];

/**
 * 入力値を'ASC'または'DESC'、無効な値ならnullに変換する
 */
function normalizeAscDesc(string $name): ?string
{
    return ASCDESC[$name] ?? null;
}

/**
 * ソート順の昇順または降順をセットする
 *
 * @param 'ASC'|'DESC' $ascdesc
 */
function setAscDesc(string $ascdesc): void {}	# 実装は省略

$order = filter_var($_GET['order']);
$ascdesc = normalizeAscDesc($order);
setAscDesc($ascdesc); # Parameter #1 $ascdesc of function setAscDesc expects 'ASC'|'DESC', string|null given.

このままの実装ではsetAscDesc()関数が受理すべき型を満たせず、エラーになります。PHPDocを次のように書き換えてみましょう。

/**
 * 入力値を'ASC'または'DESC'、無効な値ならnullに変換する
 *
 * @template T of string
 * @param T $name
 * @return ($name is key-of<ASCDESC> ? ASCDESC[T] : null)
 */
function normalizeAscDesc(string $name): ?string
{
    return ASCDESC[$name] ?? null;
}

\PHPStan\dumpType(normalizeAscDesc('昇順'));	# Dumped type: 'ASC'
\PHPStan\dumpType(normalizeAscDesc('降順'));	# Dumped type: 'DESC'
\PHPStan\dumpType(normalizeAscDesc('hoge'));	# Dumped type: null
\PHPStan\dumpType(normalizeAscDesc(random_bytes(1)));	# Dumped type: 'ASC'|'DESC'|null`
  • 関数・メソッドのPHPDocに書く@templateは型の一時変数宣言のようなものです
    • T が一時変数となる型名で、それはstring型のサブタイプだと宣言します
  • @param T $name によって、呼び出し側の引数の型が代入されます
  • @return ($name is key-of<ASCDESC> ? ASCDESC[T] : null)で代入された型を用いて戻り値の型を決定します
    • $name is key-of<ASCDESC> で引数が配列定数のキーに存在するかをチェックします
    • ASCDESC[T]で呼び出し型の値を使って配列にアクセスできます
    • 入力値が不定で配列のキーかどうか確定できない場合は、条件の結果のユニオンになります

PHPStanはこのようなところまではPHPDocだけで詳細な型として解決できます。ParametersAcceptorSelectorではこのような型も解決しています。私見では、このクラスはPHPStanの中で最も難解かつおもしろい実装です。

$users = [
    ['Miku', 16],
    ['Rin', 14],
    ['Len', 14],
    ['Luka', 20],
];

array_map(function ($row) {
    [$user, $age] = $row;
    \PHPStan\dumpType(compact('user', 'age'));
    # Dumped type: array{user: 'Len'|'Luka'|'Miku'|'Rin', age: 14|16|20}
}, $users);

このように、array_map()の型も$usersの配列の構造によって適切に型付けしてくれているのもParametersAcceptorSelectorです。

型の絞り込み(type narrowing)

PHPStanは制御フロー分析をサポートしており、分岐による型の絞り込みができます。

function f(string $s): void {
    if ($s === '') {
        \PHPStan\dumpType($s);	# Dumped type: ''
    } else {
        \PHPStan\dumpType($s);	# Dumped type: non-empty-string
    }
    \PHPStan\dumpType($s);	# Dumped type: string
}

以下のようにin_array()関数での絞り込みはif($order === 'ASC' || $order === 'DESC')と同様の効果があります。また、else節の中で例外を送出することで、型の絞り込みを行うこともできます。

/**
 * @phpstan-assert 'ASC'|'DESC' $order
 */
function assertOrder(string $order): void
{
    if (in_array($order, ['ASC', 'DESC'], true)) {
        \PHPStan\dumpType($order);	# Dumped type: 'ASC'|'DESC'
    } else {
        \PHPStan\dumpType($order);	# Dumped type: string
        throw new DomainException('$order は ASC または DESC を指定してください');
    }
    \PHPStan\dumpType($order);		# Dumped type: 'ASC'|'DESC'
}

例外メッセージが重要ではない場合は、単にassert(in_array($order, ['ASC', 'DESC'], true));とすることもできます。

PHPDocタグによって、型の絞り込みのための関数またはメソッドを独自定義できます。

  • @phpstan-assertはメソッドが例外を起こさずに正常終了した場合は、タグに記載した型に絞り込まれることを表します
  • @phpstan-assert-if-trueはメソッドがtrueを返した時に、タグに記載した型に絞り込まれることを表します
  • @phpstan-assert-if-falseはメソッドがfalseを返した時に、タグに記載した型に絞り込まれることを表します

これらのPHPDocタグが記述された関数はTypeScriptの型述語関数(type predicate)やasserts型述語と似ていますが、実装からPHPStanが述語関数であることを自動で推論してくれることはありません。また、タグに記述した内容と実装が一致していることはPHPStan 2.1時点では保証されません。これは先述した条件付き型も同様です。

さらに短縮するならこう書くこともできますが、assert()に書いたコードが実行されるかはPHPの設定に依存します。

/** @phpstan-assert 'ASC'|'DESC' $order */
function assertOrder(string $order): void
{
    assert(in_array($order, ['ASC', 'DESC'], true));
    \PHPStan\dumpType($order);	# Dumped type: 'ASC'|'DESC'`
}

@phpstan-assert系タグでは、通常の型宣言とは異なる記法の型が記述できます。

  • ! 前置することで表明する型を反転する演算子
  • = 前置することで、elseが補集合ではないことを示す演算子

補集合ではない」とはどういうことでしょうか。is_string()は「文字列である」ことをチェックするので、elseは「文字列ではない」になります。では、「文字列かどうかもわからない入力値が郵便番号である」ことをチェックする関数を定義してみましょう。

/**
 * 入力値が郵便番号かどうかをチェックする
 *
 * @phpstan-assert-if-true string $v
 */
function is_postalcode(mixed $v): bool
{
    return is_string($v) && preg_match('/\A〒?[0-9]{3}-[0-9]{4}\z/u', $v) === 1;
}

$postal = filter_var($_GET['postal'] ?? '');	# Dumped type: string|false
if (is_postalcode($postal)) {
    \PHPStan\dumpType($postal);			# Dumped type: string
} else {
    \PHPStan\dumpType($postal);			# Dumped type: false
}

郵便番号ならば文字列である」と「文字列でないならば郵便番号ではない」は正しいです。しかし、この述語関数は「郵便番号でないならば文字列ではない」と述べてしまっています。もちろん、それは正しくありません。筆者は高校数学で赤点しか取ったことがないので震えるばかりです。しかし、ここで使えるのが = 前置演算子です。これを使えば適切な型付けができることは、皆様の環境でお試しください。

ここまで紹介してこなかった@varという機能があります。これはTypeScriptのasにも少し似た機能ですが、PHPStanに慣れていない人がこのような使い方をしてしまうことがあります。無情にもこの使い方には意味がなく、消しても動きはまったく変わりません。

/**
 * @param list<?DateTime> $dates
 * @return list<DateTime>
 */
function filter_dates(array $dates): array
{
    /** @var list<DateTime> $new_dates */
    $new_dates = [];

    foreach ($dates as $date) {
        if ($date instanceof DateTime) {
            $new_dates[] = $date;
            $new_dates[] = new DateTimeImmutable();
        }
    }
    \PHPStan\dumpType($new_dates);	# Dumped type: list<DateTime|DateTimeImmutable>

    return $new_dates;
    # Function filter_dates() should return list<DateTime> but returns list<DateTime|DateTimeImmutable>.
}

もちろんreturnでは警告が出ています。型宣言ではないので、あくまでPHPStanはどのような操作が行なわれたかによって型をつけ、型に違反する値が追加されても警告もありません。@varは自分の知っている型付けのための手段を使っても、うまく型がつかないときの最後の手段だと考えてください。

PHPStanの機能はこの8ページでは書ききれませんし、PHPDocの型だけに限っても全ての機能を使いこなすことは簡単ではないでしょう。この記事では「なんとなく」触れているだけでは使えないPHPStanの機能を紹介できたので、読者のみなさまもよりよく活用できるようになっていると思います。PHPStanは日々進化しているので、補足は https://scrapbox.io/php/型付けマニュアル に思い出したら書きます ?>

6

Discussion

ログインするとコメントできます