PHPStan hlásí chybu, vy ji opravíte. Jenže tou opravou jste kód paradoxně zhoršili. Jak je to možné?

Pamatuju si, jak jsem jednou projížděl výstup PHPStanu a systematicky opravoval hlášku za hláškou. Cítil jsem se produktivně. Kód je čistší, typy sedí, zelená všude. O měsíc později jsem si lámal hlavu, proč mi funkce vrací prázdný string, když by neměla. Detektivka na hodinu, přitom viník byl jasný — já a moje „oprava“.

Nejdřív důležitá věc: PHPStan dělá přesně to, co má. Upozorní vás, že funkce může vrátit null nebo false, a donutí vás se nad tím zamyslet. To je skvělé. Problém je až vaše reakce.

Nevinný příklad

Mějme funkci, která z textu odstraní nadbytečné mezery:

function normalizeSpaces(string $s): string
{
	return preg_replace('#\s+#', ' ', $s);
}

PHPStan zahlásí: Function preg_replace returns string|null but function should return string. No jasně, preg_replace může vrátit null, pokud dojde k chybě v regexu. Tak to opravíme, ne?

function normalizeSpaces(string $s): string
{
	return (string) preg_replace('#\s+#', ' ', $s);
}

PHPStan je spokojený. Commit, push, hotovo.

Jenže.

Co jste vlastně udělali

Ten původní kód byl ve skutečnosti lepší. Pokud by preg_replace někdy vrátil null — třeba kvůli … (ne, nenapadá mě proč) — PHP by vyhodilo TypeError. Fatální chyba. Tracy by se rozsvítila, v logu by se to objevilo, prostě byste se o tom dozvěděli. (Jo aha! Kvůli tomuto může vrátit null!)

Po vaší „opravě“ se null tiše přetypuje na prázdný string. Funkce vrátí "", aplikace jede dál a vy nemáte tušení, že se něco pokazilo. Data se poškodí bez jakéhokoli varování.

Gratuluju, právě jste kód zhoršili 🙂

A preg_replace není ojedinělý případ. Spousta PHP funkcí vrací false nebo null pro situace, které při normálním použití prakticky nenastanou — json_encode, ob_get_contents, getcwd, gzcompress, celá řada Intl funkcí. Pokaždé, když sáhnete po přetypování, zastavte se a položte si otázku: nezahazujete tím informaci o (nepravděpodobné) chybě?

Správné řešení

Pokud chcete uspokojit PHPStan a zároveň zachovat původní chování, použijte throw expression:

function normalizeSpaces(string $s): string
{
	return preg_replace('#\s+#', ' ', $s)
		?? throw new \LogicException('preg_replace failed');
}

Tím říkáte: „Vím, že teoreticky může nastat chyba. Pokud nastane, chci o tom vědět.“ V podstatě jste explicitně zapsali to, co tam bylo implicitně předtím — fatálku při selhání. PHPStan spokojený, kód nezhoršený.

Ale moment. Ono to jde i jednodušeji. Tyhle bagatelní chyby můžete v PHPStanu prostě ignorovat — přidat je do ignoreErrors v phpstan.neon nebo anotací @phpstan-ignore. A je to naprosto legitimní. Vždyť to původní chování, kdy PHP vyhodí TypeError, je v podstatě to, co chcete. Proč byste kvůli tomu měnili kód?

Ještě lepší je problém vůbec nemít. Proto vznikají wrappery — kupříkladu Nette\Utils\Strings::replace() obaluje preg_replace a při chybě vyhodí výjimku. Podobně Nette\Utils\Json::encode() místo json_encode. Použijete jednu funkci a problém zmizí — žádný null, žádný false, nic k řešení.

Další možnost je vyřešit to na úrovni PHPStanu rozšířením, které u vybraných funkcí odstraní false nebo null z návratového typu. Například nette/phpstan-rules tohle dělá pro desítky PHP funkcí. U regexových funkcí navíc kontroluje, zda je pattern konstantní řetězec — pokud ano, null z typu odstraní, protože chyba v regexu nenastane.

Je to samozřejmě opinionated přístup. A to mi na PHPStanu přesně vyhovuje — je přísný, a můžu si ho přizpůsobit rozšířením podle svého.


Tichá chyba je horší než hlasitá. A (string) je ten nejtišší způsob, jak ji vyrobit.