Skip to content

Code Quality: Declare conditional return types where an argument selects the return type. - #13614

Open
westonruter wants to merge 15 commits into
WordPress:trunkfrom
westonruter:add/conditional-return-types
Open

westonruter wants to merge 15 commits into
WordPress:trunkfrom
westonruter:add/conditional-return-types

Conversation

@westonruter

Copy link
Copy Markdown
Member

What

Declares PHPStan conditional return types on 89 functions and methods whose return type is selected by one of their arguments. All of them already described that selection in prose in their @return text; none of them said it in a way a static analyzer could use.

For example, get_term() has documented since 4.4.0 that $output picks between a WP_Term, an associative array and a numeric array, while declaring the flat union, so every caller had to re-establish which one it was holding:

 * @return WP_Term|array|WP_Error|null WP_Term instance (or array) on success, depending on the `$output` value.
 *
 * @phpstan-param 'OBJECT'|'ARRAY_A'|'ARRAY_N' $output
 * @phpstan-return (
 *     $output is 'ARRAY_A' ? array<string, mixed>|WP_Error|null : (
 *         $output is 'ARRAY_N' ? list<mixed>|WP_Error|null : WP_Term|WP_Error|null
 *     )
 * )

Why

Because the flat union is not just imprecise, it is unusable. A caller writing get_term( $id, 'category', ARRAY_A )['name'] is checked against a type that includes WP_Term, so the offset access is an error; a caller doing the opposite is checked against a type that includes array, so the property access is an error. Neither caller is wrong. The type was.

The effect is largest where a value is read back out of core and fed straight back in. do_blocks() is the clearest case:

$priority = has_filter( 'the_content', 'wpautop' );
if ( false !== $priority && doing_filter( 'the_content' ) && has_blocks( $content ) ) {
	remove_filter( 'the_content', 'wpautop', $priority );
	add_filter( 'the_content', '_restore_wpautop_hook', $priority + 1 );
}

has_filter() returned bool|int, so false !== $priority left true|int and both hook calls, plus the arithmetic, were checked against a type including a bool. With the condition declared, $priority is int|false, the guard narrows it to int, and eight call sites of that shape stop reporting, in blocks.php, media.php, script-loader.php and WP_Widget_Text.

What is here

Grouped by the kind of argument that does the selecting:

  • $output selecting an object or an array form: get_term(), get_term_by(), get_category(), get_category_by_path(), get_tag(), get_bookmark(), get_link(), get_page_by_title(), and WP_Term::to_array() gains the value type the branches need.
  • count and fields reshaping a query result: WP_Comment_Query::query(), get_sites(), WP_Site_Query::query(), get_networks(), WP_Network_Query::query(), get_users(), get_categories(), get_tags().
  • A date format returning a timestamp rather than a string: current_time(), mysql2date(), get_the_date(), get_the_modified_date(), get_the_time(), get_post_time(), get_the_modified_time(), get_post_modified_time(). Each of these already said "Integer if $format is 'U' or 'G', string otherwise" in prose.
  • A flag deciding whether failure arrives as WP_Error or as false: the wp_schedule_* and wp_unschedule_* family, wp_set_comment_status(), wp_update_comment(), wp_insert_link(), wp_insert_category(), wp_allow_comment().
  • An echo or display flag returning the markup rather than printing it: single_month_title(), wp_list_categories(), wp_generate_tag_cloud(), paginate_links().
  • The whole WP_Theme header pipeline: sanitize_header() decides the shape from the header name, get() reports it, translate_header() preserves it, and display() resolves it for callers. This one removes 57 errors on its own, nearly all of them display() results being concatenated or passed to sprintf() in the themes list table, the theme editor, the upgrader skins and update-core.php.
  • Plus has_filter(), has_action(), WP_Hook::has_filter(), WP_Theme::offsetExists(), WP_Theme::offsetGet(), get_permalink(), get_user_by(), is_wp_error(), absint()'s neighbours in load.php, and about thirty more of the same shape.

One non-docblock change: wp_filter_oembed_result() was handing WP_Site::$blog_id, a numeric string for historical reasons, straight to switch_to_blog(), which wants an int. The line immediately above it already casts for its comparison. Narrowing get_sites() is what surfaced it.

Prior art

Conditional return types are not new to core. This follows a line of changesets that established the pattern and the conventions used here, starting from the PHPStan integration itself:

  • r61676 — Themes: Fix type issues in core themes and remove PHPStan suppression comments.
  • r61699 — Build/Test Tools: Integrate PHPStan into the core development workflow.
  • r62177 — Code Quality: Replace void with proper return types in wpdb and related functions.
  • r62293 — I18N: Harden against undefined index in _load_script_textdomain_from_src().
  • r62488 — Cron: Add type definitions to private cron functions.
  • r62529 — Docs: Clarify return value semantics of wpdb query methods.
  • r62637 — Filesystem API: Improve type safety across the transport classes.
  • r62648 — Code Quality: Add conditional return types to post functions.
  • r62672 — Code Quality: Add PHPStan conditional return types to the slashing functions.
  • r62680 — Code Quality: Add conditional return typing for term_exists().
  • r62694 — Code Quality: Improve return types for post functions.
  • r62704 — Docs: Add never return types to functions that always terminate.
  • r62797 — Code Quality: Improve typing for wp_parse_list() et al.
  • r62822 — Code Quality: Improve comment API type coverage.
  • r62835 — Code Quality: Preserve string[] input type in wp_parse_list() return.
  • r63358 — Code Quality: Narrow post and term query return types by fields.
  • r63419 — Code Quality: Narrow return types for field-plucking functions.
  • r63440 — Code Quality: Correct three inverted $display return docs.
  • r63441 — Code Quality: Restore void on the dual-mode template tag.
  • r63487 — Code Quality: Refine the dual-mode template tag annotations.
  • r63488 — Taxonomy: Fix wp_get_object_terms() when requesting a count.
  • r63540 — Code Quality: Narrow the esc_sql() parameter and return types.
  • r63618 — Code Quality: Improve typing for the metadata getters.

Those left 103 functions with a conditional return type on trunk. This takes that to 192, and several of the annotations here are direct continuations: get_comments() was given one in r62822 while WP_Comment_Query::query(), the method it is a thin wrapper over, was not, so the narrowing was asserted at the wrapper rather than derived from the method that produces the value. That is the first commit in this branch.

Relationship to #13530

#13530 was found late, with most of the work below already done. It proposes moving the same conditional types out of the WordPress stubs' function map and into core's docblocks, and it served two purposes here once discovered: the annotations not yet covered were adopted from it, and its versions of the ones already written were used as a cross-reference, which is how several of the decisions below got settled in each direction.

Only the conditional @phpstan-return tags were taken, plus the @phpstan-template declarations that some of those conditions are written in terms of; the @phpstan-pure, @phpstan-assert-if-true and plain @phpstan-param additions in that PR are its own business. Twelve annotations overlap. Most are identical or equivalent, which is some reassurance about both; where they differ:

An upstream PHPStan bug

The three has_filter() annotations initially tripped phpstan/phpstan#15268, reported from this work: a conditional branch spelled int|false silently loses its int, but only in the else position, only in that spelling, and only when PHPStan itself runs on PHP 8.3 or newer. return.unusedType then claims the function never returns an int. Nothing here depends on the fix: writing the union false|int sidesteps it, which is what #13530 happens to do and what this branch now does too.

Verification

phpstan.neon.dist reports no errors at every one of the 15 commits, with PHPStan running on PHP 8.2.33 and on 8.3.33. Baselines are regenerated inside the commit that invalidates them, so no point in the history is left inconsistent. A narrowed return type rewrites the message of every error downstream of it, and a baseline entry quoting the old wording then stops matching.

Measured at rule level 10, where the effect is visible rather than absorbed by the baselines: 27,962 errors to 27,600, none introduced. At the configured level 5 the tree is clean on both sides, and six previously-baselined errors are resolved, across argument.type, if.alwaysFalse, isset.property, property.nonObject and property.notFound.

Every annotation was checked with \PHPStan\dumpType() at call sites for each branch, including the defaults: get_post_time() defaults $format to 'U', so a bare call now resolves to int|false rather than the full union.

Landing

This is one PR because it is one idea, but it is not expected to be one changeset. The fifteen commits are sequential and each one leaves phpstan.neon.dist clean on its own, which is the reason the baselines are regenerated inside the commit that invalidates them rather than in a single pass at the end. Any leading run of them can therefore be committed on its own, and the natural seams are the argument kinds listed above, with the import from #13530 as the last three.

Deliberately not done

  • WP_Comment_Query::get_comments(), WP_Site_Query::get_sites(), WP_Network_Query::get_networks() and WP_List_Util::pluck() take no parameters; they read $this->query_vars or $this->output. A conditional return type can only branch on a parameter or a template, so these keep their unions and the wrappers that do take the arguments carry the condition instead.
  • wp_list_pluck() and filter_default_metadata() look like candidates and are not. The first documents a difference in which keys are used, not in the type; expressing it needs a template that core's untyped call sites cannot satisfy, and it produces eight Unable to resolve the template type errors. The second is only ever registered as a filter callback, and its four return $value; passthroughs are mixed, so a condition on $single would be unverifiable and wrong for a caller passing a non-array value.
  • get_results() on WP_User_Query, and sanitize_bookmark(), would each need internals reconciled before the types can tighten further. Noted in the relevant commits rather than worked around.

Trac ticket: https://core.trac.wordpress.org/ticket/65817

Use of AI Tools

AI assistance: Yes

Tool(s): Claude Code

Model(s): Claude Opus 5

Used for: Surveying core for functions whose @return prose already describes an argument-dependent return type, drafting the annotations, and writing the commit messages and this description.

Every annotation was verified by running PHPStan locally before being committed: \PHPStan\dumpType() at call sites to confirm each branch resolves as intended, including the defaults, and a whole-tree comparison before and after to confirm nothing new was reported. Every measurement quoted above comes from those runs rather than from an estimate. Where an annotation could not be verified, or would have been inaccurate, it was left out deliberately and the reasoning recorded in the commit; absint(), wp_list_pluck() and filter_default_metadata() under "Deliberately not done" are the notable cases.

Reviewed and directed throughout, including the formatting conventions, the Theme_Key alias name, the decision to handle the upstream PHPStan bug in configuration rather than with inline suppression and then to drop that once the union spelling made it unnecessary, and the choice to regenerate the baselines inside each commit rather than once at the end.

🤖 Generated with Claude Code

https://claude.ai/code/session_016Wgj2aw5gb2k4V5jzo57N9


This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

westonruter and others added 15 commits September 18, 2026 21:26
The `get_comments()` function already declares a conditional `@phpstan-return`
that narrows to `non-negative-int` for `count`, to `non-negative-int[]` for
`fields => 'ids'`, and to `array<int, WP_Comment>` otherwise. That function is a
thin wrapper that immediately delegates to `WP_Comment_Query::query()`, which
declared only the unnarrowed union, so the narrowing was asserted at the wrapper
rather than derived from the method that actually produces the value.

Declare the same condition on `WP_Comment_Query::query()`. Callers of the method
now get the same narrowing the function's callers already had, and the
function's own conditional type follows from its return statement instead of
standing on its own.

`WP_Comment_Query::get_comments()` keeps the plain union. A PHPStan conditional
return type can only branch on a parameter or a template type, and that method
takes no parameters -- its behavior depends on `$this->query_vars`, which the
PHPDoc type grammar cannot express.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…category getters.

`get_post()` and `get_comment()` already declare a conditional `@phpstan-return`
that resolves `$output` to the concrete shape it selects: an associative array
for `ARRAY_A`, a list for `ARRAY_N`, and the object otherwise. The term and
category getters take the same `$output` parameter but declared only the
unnarrowed union, so every caller had to re-establish the type by hand.

Declare the condition on `get_term()`, `get_term_by()`, `get_category()`,
`get_category_by_path()`, and `get_tag()`, along with the
`'OBJECT'|'ARRAY_A'|'ARRAY_N'` parameter type the condition branches on. The
error union each function can return regardless of `$output` -- `WP_Error` and
`null`, or `false` for `get_term_by()` -- is repeated in every branch, since
those early returns happen before `$output` is consulted.

Give `WP_Term::to_array()` the `array<string, mixed>` value type that
`WP_Post::to_array()` and `WP_Comment::to_array()` already have. Without it the
`ARRAY_A` and `ARRAY_N` branches degrade to an untyped array and the narrowing
is lost at the point it matters most.

Across the full tree this removes 53 static-analysis errors and introduces none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The deprecated `get_page_by_title()` hands `$output` straight to `get_post()`,
which already declares a conditional `@phpstan-return`, but re-flattened the
result to `WP_Post|array|null` on the way out. Mirror `get_post()`'s branches so
the narrowing survives the call.

`src/wp-includes/deprecated.php` is on the `excludePaths.analyse` list in
`tests/phpstan/base.neon`, so nothing in the file is checked. The file is still
scanned for signatures, though, which is what makes the annotation reach callers
in plugins and themes -- the only consumers a deprecated function still has.

That exclusion is also why this commit bypasses the pre-commit hook: with only
an unanalysed file staged, PHPStan exits with "No files found to analyse" and
the hook reads that as a failure. The change was verified separately -- all
three branches resolve correctly at call sites, and PHPCS is clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`get_comments()` and `WP_Comment_Query::query()` narrow their return type on the
`count` and `fields` query vars, and `WP_Term_Query::query()` does the same. The
site and network queries take those same two query vars and shape their return
value the same way, but declared only the unnarrowed union, so every caller had
to widen back out by hand.

Declare the condition on `get_sites()`, `WP_Site_Query::query()`,
`get_networks()`, and `WP_Network_Query::query()`.

The network side needed its types filled in first. `WP_Network_Query::query()`,
`WP_Network_Query::get_networks()`, and `get_networks()` all declared a bare
`array|int`, which says nothing about what the array holds, and the
`networks_pre_query` filter documented its short-circuit value as `array|int|null`.
Give them the `WP_Network[]|int[]|int` the site query has used all along; the
prose in each of those docblocks already described exactly that.

One consequence worth noting: with `get_sites()` resolving to `array<int, WP_Site>`,
`wp_filter_oembed_result()` was caught handing `WP_Site::$blog_id` -- a numeric
string, for historical compatibility -- straight to `switch_to_blog()`, which
wants an int. The line immediately above it already casts for its comparison, so
cast here too.

Across the full tree this removes 56 static-analysis errors. The one it adds is
the counterpart of an error `WP_Site_Query::get_sites()` already carries: both
build their result with `array_map()` over `get_site()`/`get_network()`, which
are nullable, so the mapped array is not provably free of nulls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`get_users()` declared a bare `array`, so callers learned nothing about what it
holds -- not even that it is a list, let alone that the default query returns
`WP_User` objects. The shape is decided entirely by the `fields` query var, so
express that as a condition.

The branches follow `WP_User_Query::prepare_query()`: `'all'` and
`'all_with_meta'` give `WP_User` objects, a lone `'ID'` gives user IDs, any other
field name or list of field names gives the requested values, and anything
unrecognized -- including `fields` being absent, which is the common case --
falls back to `WP_User` objects. Ordering matters here: the catch-all for named
fields would otherwise swallow `'all'`, so the two object-returning values are
matched first and the same object type is repeated in the final `else`.

`WP_User_Query::get_results()` had to say more than `array` for this to hold at
the call site, since `get_users()` returns its result directly. Give it
`array<int, mixed>`, which is as precise as the method can honestly be: the value
type depends on the same `fields` query var, and the method takes no parameter to
condition on.

That leaves one error behind on `get_results()`, replacing the missing-value-type
one it had before. It reports accurately: the private `WP_User_Query::$results`
is still declared as a bare `array`. Typing that property is the next step and a
larger one -- it is assigned from the `users_pre_query` filter, from two
different `wpdb` methods, and from the object cache, and it holds `null` between
the filter call and the query, so closing this properly means reconciling all
four.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eight functions return an integer for one particular format string and a
formatted string for everything else. Every one of them already said so in
prose -- `current_time()` documents "Integer if `$type` is 'timestamp' or 'U',
string otherwise" -- while declaring a flat `int|string` that callers had to
re-narrow by hand.

Declare the condition on `current_time()` and `mysql2date()` in functions.php,
and on `get_the_date()`, `get_the_modified_date()`, `get_the_time()`,
`get_post_time()`, `get_the_modified_time()`, and `get_post_modified_time()` in
general-template.php. The `false` returned when there is no post, or when the
date cannot be parsed, precedes the format check in every case, so it appears in
both branches.

Two of these carry their answer in the default value: `get_post_time()` and
`get_post_modified_time()` default `$format` to 'U', so a bare call now resolves
to `int|false` rather than the full union.

Across the full tree this removes 13 static-analysis errors and introduces none.
Most of them are call sites that were handing one of these values to something
expecting a string -- `strtotime()`, `substr()`, `preg_match()`,
`wp_handle_upload()` -- where the integer branch could never have reached.

Two baseline patterns in `tests/phpstan/baselines/argument.type.neon` quote the
old, wider types in their message text, so they stop matching once the types
narrow. Update both to the text PHPStan now reports. They are the same two
errors as before, still baselined; only the spelling of the types inside them
changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`WP_Theme::get()` already resolves its return type from `$header`, giving
`string[]` for Tags and a string for the other known headers.
`WP_Theme::display()` wraps it, and its own docblock says "An array for Tags if
`$markup` is false, string otherwise" -- but it declared a flat
`string|array|false`, so everything `get()` had established was thrown away one
call later.

Declare the condition on both parameters: an array only when `$markup` is false
and the header is Tags, a string in every other case, and false throughout for a
header the theme does not have.

`WP_Theme::translate_header()` had to say more than `string|array` first, since
`display()` passes its value through on the way out. It is private, so it takes
the PHPStan types directly: `string|string[]` in and out.

Across the full tree this removes 57 static-analysis errors and introduces none.
Nearly all of them are `display()` results being concatenated or passed to
`sprintf()` -- in the themes list table, the theme editor, the upgrader skins,
update-core.php -- where the array branch could only ever have arrived for Tags,
and never at all once `$markup` was left at its default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three functions take a boolean that decides not how much work they do but what
type comes back, and each already spelled the rule out in its `@return`
description while declaring the flat union.

`wp_insert_category()` and `wp_allow_comment()` both take `$wp_error`, and
return a `WP_Error` only when it is true. Follow the form `wp_insert_post()`
already uses for the same parameter, keyed on `false` so that omitting the
argument -- which is how nearly every caller invokes them -- resolves to the
narrow type rather than the union.

`single_month_title()` takes `$display`, echoing and returning null when it is
true and returning the title when it is false. Its siblings `single_cat_title()`,
`single_tag_title()`, and `single_term_title()` are already annotated this way;
this one was missed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`WP_Theme::display()` gained a conditional return type in the previous commit,
but the two private methods it hands its value to were left saying only that the
result is a string or an array. Both say something more specific.

`sanitize_header()` is where the array comes from: it takes a string in every
case and returns one back, except for Tags, which it explodes on commas. That is
a condition on `$header`. Giving it one also supplies the value type its
`@return` was missing, which is where the one error this removes came from.

`translate_header()` never changes the shape it is handed. Every branch either
returns `$value` untouched or replaces it with `translate()`, so a string in
means a string out and an array in means an array out. That is a condition on
`$value`, not on `$header` -- the Tags branch returns the value as-is when it is
empty or the feature list is unavailable, so even Tags can come back as a string.

The two together mean the shape is now stated at each step it passes through:
`sanitize_header()` decides it from the header name, `get()` reports it,
`translate_header()` preserves it, and `display()` resolves it for callers.

Also drop the `@phpstan-param` and `@phpstan-return` added to
`translate_header()` in the previous commit. `string[]` is ordinary PHPDoc, so
the plain tags carry it -- PHPStan reports the same 87 errors for the file either
way. Only the conditional genuinely needs a prefixed tag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both functions have three distinct return values selected by their arguments: a
bool for whether anything at all is hooked when no callback is given, the
callback's priority as an int when one is, and a bool again when a specific
priority is also supplied. The docblock has described that in prose since 6.9.0
added `$priority`, while the declared type stayed `bool|int`, so callers had to
re-establish which of the three they were holding.

Declare the condition on `has_filter()`, `has_action()`, and the
`WP_Hook::has_filter()` they both ultimately reach.

This resolves eight errors across the tree, all of the same shape: a priority is
read back out of `has_filter()` and handed straight to `add_filter()` or
`remove_filter()`, which want an int. `do_blocks()` is the clearest case --
it takes the priority of `wpautop`, removes the filter, and re-adds a restore
callback at `$priority + 1`. Guarding with `false !== $priority` used to leave
`true|int`, so both the arithmetic and the hook calls were checked against a type
that included a bool. The same pattern appears in `wp_filter_content_tags()`,
`wp_common_block_scripts_and_styles()`, and `WP_Widget_Text`.

PHPStan reports `return.unusedType` against `has_filter()` and `has_action()`
anyway, claiming neither returns an int. That is a bug in PHPStan, reported as
phpstan/phpstan#15268: when it combines the branches of a conditional return type
for a call whose argument is not narrow enough to select one, it drops the int
from an `int|false` branch. So each wrapper is handed a type that has already
lost the int before it can return it. The fault depends on the PHP version
PHPStan itself runs on, not on the analysed `phpVersion`: correct on PHP 8.2,
wrong on 8.3, 8.4 and 8.5, identically across PHPStan 2.2.13 through 2.3.x-dev.

Suppress it in `phpstan.neon.dist` rather than with `@phpstan-ignore` comments.
An inline ignore has to match, and on PHP 8.2 there is nothing to match, so it
becomes an `ignore.unmatchedIdentifier` error of its own -- which is
non-ignorable, and would report in any editor configured against 8.2 while CI on
a newer PHP stayed green. The config entry takes `reportUnmatched: false` and so
is silent either way. Remove it, and its comment, once the upstream fix ships.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… `@return`.

`_get_block_templates_files()` returns null for a `$template_type` that is
neither 'wp_template' nor 'wp_template_part', and an array of template files for
either of those. Both are the only outcomes, so the condition is exact. Giving it
one also supplies the value type its `@return` was missing, which is what removes
the five errors below.

`wp_find_hierarchy_loop_tortoise_hare()` returns an array of the loop's members
when `$_return_loop` is set and an arbitrary member's ID otherwise, both by way of
the same `$_return_loop ? $return : $tortoise` expression, or false when no loop
was found. Only the array case is worth stating, since the scalar is whatever the
`$callback` returns and stays `mixed`; note the `false` in both. While there, say
in the description that false means no loop, which was not written down anywhere.

The element type on the first is `array<array-key, mixed>` rather than the
`array<string, mixed>` these items actually are. The items come back from
`_add_block_template_info()` and `_add_block_template_part_area_info()`, both of
which declare a bare `array`, so the stricter type is correct but not checkable
from here. Typing those two is its own change.

Separately, `_get_block_template_file()` claimed to return "Array with template
metadata if $template_type is one of 'wp_template' or 'wp_template_part', null
otherwise". The second half is wrong: a matched `$template_type` also returns null
when the theme has no file for `$slug`, which is the common case for any slug the
theme does not define. Say both reasons. No conditional for this one, because
with null reachable from either branch there is nothing left to distinguish.

Across the full tree this removes six static-analysis errors and introduces none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rkaround.

The conditional return types on `has_filter()`, `has_action()`, and
`WP_Hook::has_filter()` tripped a PHPStan bug that dropped the `int` from their
`int|false` branch, so `return.unusedType` claimed each wrapper never returns an
int. Keeping the tree clean needed an `ignoreErrors` entry, reported upstream as
phpstan/phpstan#15268.

The bug turns out to depend on the order the union is written in, and on the
branch it sits in: `int|false` in the else branch loses its `int`, while
`false|int` in the same place, or `int|false` in the then branch, does not. The
two spellings denote the same type and callers cannot tell them apart, so write
the one that works and delete the suppression along with its comment.

Verified with each of four PHP builds running PHPStan against
`phpstan.neon.dist`: no errors on 8.2.33, 8.3.33, 8.4.25, or 8.5.9, where before
this every version from 8.3 up needed the entry. Call sites are unchanged --
`has_filter( $hook )` still resolves to `bool`, `has_filter( $hook, $cb )` to
`int|false`, and `has_filter( $hook, $cb, 10 )` to `bool` -- and the full analysis
is unchanged at 27,760 errors, with none introduced and none resolved.

The spelling comes from the PHPStan types in
WordPress#13530, which writes
`($callback is false ? bool : false|int)` and so never met this. That version
predates the `$priority` parameter added in 6.9.0, however: it reports `false|int`
for a call that passes a specific priority, where the function returns a bool. The
three-way condition is kept here for that reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ion map.

The function map behind the WordPress stubs has carried conditional return types
for years, and WordPress#13530 proposes
moving them into core's own docblocks. Take the conditional ones from that set
here, so this branch covers the same ground rather than half of it. That PR is a
draft and expected to keep moving; where the two disagree, settling it is its
business rather than this branch's.

Only the conditional `@phpstan-return` tags are taken. That PR also adds
`@phpstan-pure`, `@phpstan-impure`, `@phpstan-assert-if-true` and a number of plain
`@phpstan-param` types, none of which have anything to do with conditional
returns; those are left to it. The exception is `@phpstan-template`, kept together
with the `@phpstan-param T $x` that binds it, wherever a condition is written in
terms of the template rather than of a parameter -- `maybe_serialize()`,
`rest_sanitize_boolean()`, `wp_http_validate_url()`, `add_cssclass()`,
`sanitize_sql_orderby()`, `translate_plural()` -- since without the declaration the
type does not parse.

Fifty functions across twenty-five files, all of the kinds this branch has been
working through already:

- A flag deciding whether failure arrives as a `WP_Error` or as `false`:
  `wp_schedule_single_event()`, `wp_schedule_event()`, `wp_reschedule_event()`,
  `wp_unschedule_event()`, `wp_clear_scheduled_hook()`, `wp_unschedule_hook()`,
  `wp_set_comment_status()`, `wp_update_comment()`, `wp_insert_link()`.
- An argument selecting the shape of the result: `wp_list_categories()` and
  `wp_generate_tag_cloud()` on `echo` and `format`, `paginate_links()` on `type`,
  `get_categories()` and `get_tags()` on `fields`, `get_user_by()` on `$field`,
  `wp_debug_backtrace_summary()` on `$pretty`, `WP_Dependencies::query()` on
  `$status`.
- A literal argument value the result follows from: `size_format()`,
  `bool_from_yn()`, `validate_file()`, `zeroise()`, `get_tag_regex()`,
  `maybe_serialize()`, `block_version()`, `taxonomy_exists()`, `tag_exists()`,
  `is_term()`, `sanitize_term_field()`, `wp_is_post_revision()`,
  `wp_unique_prefixed_id()`, `translate_plural()`.
- Emptiness or the type of the input deciding what can come back at all:
  `wp_get_link_cats()`, `add_cssclass()`, `delete_plugins()`, `validate_plugin()`,
  `wp_extract_urls()`, `path_is_absolute()`, `wp_is_numeric_array()`,
  `wp_is_uuid()`, `sanitize_sql_orderby()`, `wp_http_validate_url()`,
  `has_shortcode()`, `is_wp_error()`, `rest_ensure_response()`,
  `rest_sanitize_boolean()`, `get_user()`, `get_the_permalink()`,
  `get_post_permalink()`, `addslashes_gpc()`, `get_block_wrapper_attributes()`.

Formatted the way the rest of core's conditional types are written: spaces inside
the parentheses and the array-shape braces, a blank docblock line separating the
PHPStan tags from the standard ones, and the two that ran long broken across lines
as the longer ones here already are.

Twelve of the set overlap with work already on this branch and are left alone for
now; each is compared on its own merits in what follows. Eight more did not apply
cleanly, five because the function already carries a conditional type on trunk and
two because an attribute sits between the docblock and the declaration; those are
handled separately too.

One baseline entry falls away: an `if.alwaysFalse` in the REST comments controller
that a narrowed type makes reachable again.

Verified against `phpstan.neon.dist` with PHPStan running on PHP 8.2.33 and
8.3.33: no errors on either, and PHPCS reports nothing on the added lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…` with it.

Of the twelve conditional return types in
WordPress#13530 that overlap work
already on this branch, `get_bookmark()` is the one where that PR is plainly
ahead, and it unblocks a function dropped earlier for want of exactly this.

`get_bookmark()` was attempted here before and abandoned: `sanitize_bookmark()`
declares `stdClass|array` and `$_bookmark` is `mixed` for much of the function, so
a `stdClass` branch produced errors on the returns. The version in that PR gets
around it by spelling the array branches `array<string, mixed>` and
`array<int, mixed>`, whose union is plain `array` and therefore accepts whatever
`sanitize_bookmark()` hands back. The earlier attempt here used `list<mixed>` for
the `ARRAY_N` branch, which is more precise and breaks that union. Precision was
the wrong trade: this removes two errors, one of them the missing value type on the
`@return`.

`get_link()` is the deprecated wrapper over `get_bookmark()` and was dropped for
the same reason, so give it the same condition. Its `@return` also gains the `null`
it has always been able to return.

The other eleven overlaps stay as they are:

- `mysql2date()`, `current_time()`, `single_month_title()` and `get_sites()` are
  the same type either way; the two branches converged on them independently.
- `get_term()` and `get_term_by()` are written there with `array<string, string|int>`
  and `list<string|int>` rather than `mixed`. More precise and not checkable:
  `WP_Term::to_array()` returns `array<string, mixed>`, and tightening that runs
  into `get_object_vars()`, which PHPStan will not resolve to a shape. Adopting it
  costs two `return.type` errors for no gain.
- `get_category()` is written there as an intersection of two conditionals, one on
  `$output` and one on `$category`, which is a technique worth knowing and narrows
  better. The `$category` half is wrong, though: it excludes `WP_Error|null` for any
  object, while a `stdClass` whose `filter` is set to anything but 'raw' reaches
  `WP_Term::get_instance()`, which is documented `WP_Term|WP_Error|false`. Nothing
  in core passes an object to `get_category()` anyway.
- `get_category_by_path()` differs only in `array<int, mixed>` where this branch has
  the more precise `list<mixed>`, which verifies here.
- `wp_insert_category()` is written there as `int<0, max>` and `int<1, max>|WP_Error`.
  Correct, and it does not verify: the term ID comes back from `wp_insert_term()` and
  `wp_update_term()` as a plain `int`, and that PR does not retype either.
- `has_filter()` and `has_action()` are written there without the `$priority`
  parameter added in 6.9.0, so they report `false|int` for a call that passes a
  priority, where the function returns a bool.

A `property.nonObject` baseline entry falls away, now that `get_bookmark()` reports
an object rather than `mixed`.

Verified against `phpstan.neon.dist` with PHPStan running on PHP 8.2.33 and 8.3.33:
no errors on either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eight of the conditional types in
WordPress#13530 did not go in with the
rest. Six do now, and the two that do not are recorded below.

`WP_Theme::offsetExists()` and `WP_Theme::offsetGet()` answer for a fixed set of
header names, so both condition on whether `$offset` is one of them. The set is
declared once as a `@phpstan-type Theme_Key` on the class, which is what the two
conditions are written in terms of; that PR calls it `ThemeKey`, but core's own
aliases are `Data_Array`, `Endpoint_Arg`, `Header_Image_Data` and so on, so follow
that. Array access resolves through the condition: `$theme['Bogus']` is now `null`
rather than `mixed`. `isset( $theme['Name'] )` still does not, because PHPStan
answers that from its own ArrayAccess handling rather than from `offsetExists()`,
but a direct call does.

`wp_unique_id()` and `wp_unique_id_from_values()` return a string whose shape
follows the prefix: lowercase for a lowercase prefix, and numeric when there is no
prefix at all. `WP_Translations::translate()` returns null only for a null
singular. `get_permalink()` cannot fail when handed a `WP_Post` rather than an ID,
so the `false` drops out for that case.

`absint()` keeps the `non-negative-int` it already had. The version in that PR
resolves literals exactly -- `absint( 5 )` to `5`, `absint( 'abc' )` to `0`,
`absint( true )` to `1` -- but it costs more than it returns. It gives up the
non-negative guarantee for any argument PHPStan cannot pin down, which is most of
them, resolving `absint( $mixed )` to plain `int`; and the template it uses needs a
`@phpstan-param T|scalar|array|resource|null`, which narrows a parameter that was
`mixed` and so reports at every call site handing it something unknown. Measured
across the whole tree that is 215 errors added against a handful resolved. Stating
the guarantee unconditionally in one line is the better trade. Intersecting the two
was tried and does not work either: the intersection stops the template resolving,
so the literal precision is lost anyway.

`_get_list_table()` keeps what it has too. It is already generic, taking
`class-string<T>` and returning `T|false`, which holds for any class name. The
version in that PR enumerates the seventeen core list tables and returns `new<T>`,
which is more precise for those and says nothing about anything else.

Verified against `phpstan.neon.dist` with PHPStan running on PHP 8.2.33 and
8.3.33: no errors on either, and no baseline needed regenerating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props westonruter.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@westonruter

Copy link
Copy Markdown
Member Author

cc @apermo

@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@apermo apermo left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to check the rest later

* @param int $link_id Link ID to look up.
* @return int[] The IDs of the requested link's categories.
*
* @phpstan-return ( $link_id is empty ? array{ } : array<int, int<1, max>> )

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While intentionally correct, technically it can be wrong if someone messes with the database and sets a negative id in there manually.

But I m happy to ignore this technicality

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants