Follow up on review feedback for the dual-mode void conditional return types - #13371
Follow up on review feedback for the dual-mode void conditional return types#13371westonruter wants to merge 5 commits into
void conditional return types#13371Conversation
The template tags whose display flag lives in an `$args` array also accept a query string, which PHPStan cannot read, so their conditional return types end in a third branch that leaves the type a union and reports nothing. That branch tested `$args is ''|array`, treating the empty string as the only scalar that behaves like an empty argument set. `'0'` behaves the same way. `parse_str( '0', $r )` yields `array( 0 => '' )`, so `wp_parse_args( '0', $defaults )` sets no key the tag reads and the flag keeps its default: the call prints and returns nothing, exactly as `''` does. It was resolving to the retrieval type instead, so consuming its meaningless result went unreported. Widening the branch to `''|'0'|array` closes that, and matches the flag conditions themselves, which already spell the falsy set out as `false|0|''|'0'`. Ten tags are affected: `wp_list_authors()`, `wp_list_bookmarks()`, `wp_tag_cloud()`, `wp_list_comments()`, `paginate_comments_links()`, `wp_get_archives()`, `the_title_attribute()`, `wp_list_pages()`, `wp_page_menu()` and `wp_list_users()`. Every other call shape is unchanged. A query string carrying a visible flag, and an `$args` built at runtime, both stay unions and are still reported in neither mode; an array setting the flag still resolves to the retrieval type; an empty array or a truthy flag still resolves to `void`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The return description said nothing comes back "when the required `id` or
`name` argument is missing". Neither is required and neither can go missing:
both default to `'locale'`, so a caller omitting them gets a dropdown. What the
function actually bails on is a falsy value the caller supplied for one of
them.
The `void` in the union covers exactly that bail, and until now it did nothing
— a plain union never resolves to `void`, so the annotation was silent for
every call. A conditional makes it report where the bail is visible: `''` and
`'0'` are the falsy strings the arguments are documented to hold, mirroring how
the sibling tags spell the falsy set of a `bool|int` flag as `false|0|''|'0'`.
The two shapes have to nest rather than combine. PHPStan cannot represent a
union of two unsealed array shapes with different required keys, and widens
`array{ id: ''|'0', ... }|array{ name: ''|'0', ... }` to plain
`non-empty-array` — which would ask whether `$args` has any keys at all, and
so resolve every populated array, including all seven call sites in core, to
`void`. Nested, each shape is tested on its own.
A call that supplies both arguments, sets neither, passes a query string, or
builds `$args` at runtime is unaffected and reported in neither mode.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both described their return as "String if retrieving." — naming the type rather than the content, and saying nothing at all about display mode, which is the one outcome the `void` in the union exists to convey. Every other tag of this shape states both halves. `the_date()` now names the date and the case where there isn't one: the function builds its value only when `is_new_day()`, so a post sharing a date with the one before it retrieves an empty string. That is the whole point of the function, and naming the content without the caveat would promise a date the caller may not get — the same correction already applied to `next_posts()`, `previous_posts()` and `wp_register()`. `the_modified_date()` always concatenates a date, so it takes the short form. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`print_error()` and `check_database_version()` documented `void|false` and `void|WP_Error` until r62177 replaced the `void` with `null` and added a trailing `return null;` to each. The premise was that `void` cannot belong to a union, which holds for PHP's native return types but not for PHPDoc. Both are the shape that keeps `void` elsewhere in core: the method either succeeds, with nothing to hand back, or reports a failure. `print_error()` returns `false` only when errors are suppressed or hidden; otherwise it prints and has no value to give. `check_database_version()` returns a `WP_Error` only when the server is too old, and its one caller, `wp_check_mysql_version()`, tests `is_wp_error()` and ignores the rest. Under `null` that meaningless value read as a legitimate one, which is what the `void` was there to deny — and what nineteen sibling functions documenting `void|false`, and seven documenting `void|WP_Error`, still say. Neither can carry a conditional return type: both switch on object state rather than on an argument, so the distinction is for the reader, not the analyser. Removing the explicit `return null;` changes nothing at runtime, since falling off the end returns `null` anyway, and `void` in the union is what licenses it. The rest of r62177 stands. `prepare()` and `get_row()` return a `null` their callers genuinely consume, `check_connection()` and `bail()` were corrected in the other direction because their old `void` branches die rather than return, and `get_col_info()` is documented `mixed`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fifteen dual-mode tags document a `null` in their return description while the `@return` tag itself lists only `string|void`, so the tag omits a value the prose promises and the conditional `@phpstan-return` already states. The three-way union was rejected when these annotations were written, on the grounds that no such form appeared elsewhere in core. That was wrong. Immediately before r62177 the tree held three — `WP_Block_Type::__get()` as `string|string[]|null|void`, `wpdb::get_row()` as `array|object|null|void` and one in `WP_Theme_JSON` as `null|void`. All three were removed by the same campaign that replaced `void` in unions on the premise that a union cannot hold it, which is the premise r63441 corrected. The idiom existed; it was erased by the mistake being undone here. Thirteen tags become `string|null|void` and two, `wp_tag_cloud()` and `paginate_comments_links()`, become `string|string[]|null|void`. Values come first, then `null`, then `void`, following the form the tree used before. The test is the conditional's retrieval branch, not the wording: `null` is named only where a caller can observe it. `wp_list_pages()`, `wp_page_menu()`, `wp_list_authors()`, `wp_list_bookmarks()` and `wp_list_users()` look like the same shape but are left alone — their `null` sits in the undecidable branch that covers a query string or an `$args` built at runtime, which is the union of both modes rather than a value retrieval mode can hand back, and their retrieval branch is plain `string`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
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 Core Committers: Use this line as a base for the props when committing in SVN: To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
Test using WordPress PlaygroundThe 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
For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation. |
| * 'echo' is true; nothing is returned when the 'id' or 'name' | ||
| * argument is empty. | ||
| * @phpstan-return ( | ||
| * $args is array{ id: ''|'0', ... } |
There was a problem hiding this comment.
Better use false|0|0.0|''|'0'|[]|null. While the docs say that $args['id'] and $args['name'] should be string, other values may still be passed. In those cases, PHPStan may incorrectly infer a string return type.
This may apply to other conditional return types as well.
Yes, that's what I meant. It does not follow the "under this condition, the return type is ..." pattern. "when retrieving" requires looking up when retrieval actually happens.
There is no other way to express this in the DocBlock. Doing it differently would require a PHPStan rule. And because it is a "conflict" the example added |
Follow-up to r63440 and r63441, addressing review feedback from @IanDelMar on #13359.
Five commits, each independently reviewable. Four act on points raised in that review; one corrects a claim made in the previous pull request's own description.
1. Every falsy
$argsscalar belongs in the undecidable branchThe tags whose display flag lives in an
$argsarray also accept a query string, which PHPStan cannot read, so their conditional return types end in a third branch that leaves the type a union and reports nothing. That branch tested$args is ''|array, treating the empty string as the only scalar behaving like an empty argument set.'0'behaves identically.parse_str( '0', $r )yieldsarray( 0 => '' ), sowp_parse_args( '0', $defaults )sets no key the tag reads and the flag keeps its default — the call prints and returns nothing, exactly as''does. It was resolving to the retrieval type instead, so consuming its meaningless result went unreported.The branch is now
''|'0'|array, matching how the flag conditions themselves already spell the falsy set asfalse|0|''|'0'. Ten tags are affected:wp_list_authors(),wp_list_bookmarks(),wp_tag_cloud(),wp_list_comments(),paginate_comments_links(),wp_get_archives(),the_title_attribute(),wp_list_pages(),wp_page_menu()andwp_list_users()— the nine raised in review pluswp_get_archives(), which has the same branch and the same gap.Every other call shape is unchanged. A query string carrying a visible flag and an
$argsbuilt at runtime both stay unions and are still reported in neither mode; an array setting the flag still resolves to the retrieval type; an empty array or a truthy flag still resolves tovoid.2.
wp_dropdown_languages()bails on a falsy argument, not a missing oneThe return description said nothing comes back "when the required
idornameargument is missing". Neither is required and neither can go missing: both default to'locale', so a caller omitting them gets a dropdown. The function bails on a falsy value the caller supplied.The
voidin the union covers exactly that bail, and until now did nothing, since a plain union never resolves tovoid. A conditional makes it report where the bail is visible.''and'0'are the falsy strings the arguments are documented to hold, mirroring how the sibling tags spell the falsy set of abool|intflag.The two shapes have to nest rather than combine. PHPStan cannot represent a union of two unsealed array shapes with different required keys and widens
array{ id: ''|'0', ... }|array{ name: ''|'0', ... }to plainnon-empty-array:That would ask whether
$argshas any keys at all, resolving every populated array — including all seven call sites in core — tovoid:array( 'id' => '' )void✔void✔array( 'name' => '' )void✔void✔array( 'id' => 'x', 'name' => 'y' )void✘string✔array( 'echo' => false )void✘string✔array( 'selected' => 'de_DE' )void✘string✔array()/ no argumentstring✔string✔The two rows that look right in the combined column are coincidence: the empty array passes only by failing
non-empty-array, and the two bail shapes pass only by being non-empty.src/was swept for the same construct — no other conditional in core unions array shapes.This also corrects the previous description, which filed
wp_dropdown_languages()under "not dual-mode at all — there is no argument for a condition to switch on". There is one; it is the bail condition rather than the display flag.3.
the_date()andthe_modified_date()say what they returnBoth described their return as "String if retrieving." — naming the type rather than the content, and saying nothing about display mode, which is the one outcome the
voidexists to convey.the_date()now names the date and the case where there isn't one: it builds its value only whenis_new_day(), so a post sharing a date with the one before it retrieves an empty string. Naming the content without that caveat would promise a date the caller may not get, which is the correction already applied tonext_posts(),previous_posts()andwp_register()in r63441.the_modified_date()always concatenates a date, so it takes the short form.Also raised in review was
edit_term_link(). As it stands in r63441 it already reads "HTML content when retrieving, null on failure or without the capability to edit the term. Nothing when displaying." — the same structure as the five title tags fromsingle_post_title()throughsingle_term_title(), so it is left alone.4.
voidrestored on twowpdbmethods that never return a valueprint_error()andcheck_database_version()documentedvoid|falseandvoid|WP_Erroruntil r62177 replaced thevoidwithnulland added a trailingreturn null;to each. The premise was thatvoidcannot belong to a union, which holds for PHP's native return types but not for PHPDoc — the premise r63441 corrected.Both are the shape that keeps
voidelsewhere in core: the method either succeeds, with nothing to hand back, or reports a failure.print_error()returnsfalseonly when errors are suppressed or hidden; otherwise it prints and has no value to give.check_database_version()returns aWP_Erroronly when the server is too old, and its one caller,wp_check_mysql_version(), testsis_wp_error()and ignores the rest. Undernullthat meaningless value read as a legitimate one, which is what thevoidwas there to deny — and what nineteen sibling functions documentingvoid|false, and seven documentingvoid|WP_Error, still say.Neither can carry a conditional: both switch on object state rather than on an argument, so the distinction is for the reader. Removing the explicit
return null;changes nothing at runtime, since falling off the end returnsnullanyway, andvoidin the union is what licenses it.The rest of r62177 stands.
prepare()andget_row()return anulltheir callers genuinely consume,check_connection()andbail()were corrected in the other direction because their oldvoidbranches die rather than return, andget_col_info()is documentedmixed.5.
nullnamed in the tags whose retrieval branch can return itFifteen dual-mode tags document a
nullin their return description while the@returntag lists onlystring|void, so the tag omits a value the prose promises and the conditional@phpstan-returnalready states.The three-way union was rejected when those annotations were written, on the grounds that no such form appeared elsewhere in core. That was wrong. Immediately before r62177 the tree held three:
WP_Block_Type::__get()asstring|string[]|null|void,wpdb::get_row()asarray|object|null|void, and one inWP_Theme_JSONasnull|void. All three were removed by the same campaign that replacedvoidin unions on the mistaken premise. The idiom existed; it was erased by the error being undone here.Thirteen tags become
string|null|void—single_post_title(),post_type_archive_title(),single_cat_title(),single_tag_title(),single_term_title(),wp_get_archives(),get_calendar(),edit_term_link(),the_title(),the_title_attribute(),wp_list_comments(),wp_update_php_annotation()andtwentytwenty_site_description()— and two becomestring|string[]|null|void:wp_tag_cloud()andpaginate_comments_links(). Values first, thennull, thenvoid, following the order the tree used before r62177.The test is the conditional's retrieval branch, not the wording, so
nullis named only where a caller can observe it.wp_list_pages(),wp_page_menu(),wp_list_authors(),wp_list_bookmarks()andwp_list_users()look like the same shape but are left alone: theirnullsits only in the undecidable branch covering a query string or an$argsbuilt at runtime, which is the union of both modes rather than a value retrieval mode can hand back. Their retrieval branch is plainstring, sostring|voidis already complete.Left open
One point from the review is deliberately not acted on. Of the thirty-one dual-mode return descriptions, twenty-three name the flag ("Calendar HTML when
$displayis false … Nothing otherwise."), six use retrieving/displaying ("Title when retrieving … Nothing when displaying."), and two name a second dimension as well. Folding the six into the majority form is a defensible normalization, but both phrasings are accurate and it would touch five functions beyond the one raised. Happy to add it if wanted.The suggestion to declare
@param true $deprecated_echoontrackback_url(), as php-stubs/wordpress-stubs does to signal a deprecated argument, is also left for its own ticket. It conflicts with the conditional return, which then reportsCondition "true is true" in conditional return type is always true.on the function itself, so adopting it means removing the conditional — and it reclassifies every existingtrackback_url( false )call as an argument-type error, which is a policy decision about deprecated arguments across core rather than a detail of this change.Verification
'0'now resolves to plainvoidon all ten tags and is reported when consumed, while query strings carrying a visible flag, dynamic$args, and arrays setting the flag are unchanged in both modes. Thewp_dropdown_languages()conditional resolves correctly in all six shapes tabulated above, and the combined form was verified to fail as described.phpstan-diff --changed --staged --base=HEADis clean on every commit, as enforced by the pre-commit hook.general-template.phpare the pre-existing$wpdb->prepare()ones inwp_get_archives(), on untouched lines.Tests_DBpasses for thewpdbchange: 651 tests, 985 assertions, 2 skipped. Removing the explicitreturn null;is a no-op at runtime.tests/phpstan/baselines/return.missing.neonstays deleted —voidin the union is what licenses falling off the end.Trac ticket: https://core.trac.wordpress.org/ticket/65817
Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Opus 5
Used for: Evaluating the review feedback, verifying each claim against PHPStan, and drafting the annotations and this description. The PHPStan, PHPCS and PHPUnit verification was run against the working tree, and the result has been reviewed and is owned by me.
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.