Skip to content

[6.x] Accept string pseudo-type intersections in docblocks - #11823

Merged
danog merged 2 commits into
vimeo:6.xfrom
alies-dev:fix/11821-intersection-pseudo-types
Sep 27, 2026
Merged

danog merged 2 commits into
vimeo:6.xfrom
alies-dev:fix/11821-intersection-pseudo-types

Conversation

@alies-dev

@alies-dev alies-dev commented Apr 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #11821.

Parse-time intersections like non-empty-string&lowercase-string now collapse to their single-atomic equivalent (non-empty-lowercase-string) instead of throwing InvalidDocblock: Intersection types must be all objects, Psalm\Type\Atomic\TNonEmptyString provided.

This mirrors the runtime narrowing already present in Type::intersectAtomicTypes and unblocks Laravel's Illuminate\Support\Str::lower() return type:

/**
 * @param  string  $value
 * @return ($value is '' ? '' : non-empty-string&lowercase-string)
 */
public static function lower($value)

How it works

TypeParser::getTypeFromIntersectionTree() now calls a new collapseStringPseudoTypeIntersection() helper before the existing extractKeyedIntersectionTypes(). If the helper recognizes a known string pseudo-type combination, it returns the equivalent single atomic; otherwise it returns null and the existing rejection path runs unchanged.

Design notes

  • Positive allowlist: only TNonEmptyString, TLowercaseString, TNonspecificLiteralString (and their subclasses) participate in the collapse. Anything else falls through to the original error path.
  • Exact-class narrowing: the two rules mirrored from Type::intersectAtomicTypes (non-empty-string & literal-string → non-empty-literal-string, non-empty-string & lowercase-string → non-empty-lowercase-string) use exact ::class matching rather than instanceof, so subclasses like TNumericString or TNonFalsyString paired with TLowercaseString are rejected rather than silently widened.
  • Subtype cases: is_a() handles natural subtype relationships (e.g. numeric-string & non-empty-string → numeric-string).
  • Class-hierarchy quirks: explicit handlers cover two cases where the class hierarchy lags behind semantic subtyping:
    • TNonEmptyLowercaseString extends TNonEmptyString but not TLowercaseString.
    • TNonEmptyNonspecificLiteralString extends TNonspecificLiteralString but not TNonEmptyString.

Scope

This PR addresses the specific Laravel/Str::lower() case from #11821. It is related to but does not fully close #9628 (which covers other combinations such as numeric-string & truthy-string that have no single-atomic equivalent). Those can be added incrementally on top of the same machinery.

Tests

  • tests/TypeParseTest.php — 14 new tests covering accepted combinations, subtype resolution, class-hierarchy quirks, and rejection of unsafe/unknown pairs (to lock in that no widening occurs).
  • tests/ReturnTypeTest.php — 3 functional tests including the Laravel-style conditional return type.

Verification


Note

Medium Risk
Touches core type parsing for intersection types; while behavior is narrowly allow-listed, it can change how certain docblocks are interpreted and could affect downstream analysis results.

Overview
Docblock intersections involving specific string pseudo-types are now accepted and collapsed to a single narrower atomic during parsing (e.g. non-empty-string&lowercase-string → non-empty-lowercase-string, non-empty-string&literal-string → non-empty-literal-string) instead of throwing an invalid intersection error.

TypeParser::getTypeFromIntersectionTree() calls a new helper to safely reduce allow-listed string pseudo-type pairs (including a few hierarchy edge-cases) and otherwise falls through to the existing rejection logic unchanged, with new unit/analysis tests covering accepted, subtype, and rejected combinations (including a Laravel-style conditional return type).

Reviewed by Cursor Bugbot for commit 0290de1. Bugbot is set up for automated code reviews on this repo. Configure here.

Parse-time intersections like `non-empty-string&lowercase-string` now
collapse to their single-atomic equivalent (`non-empty-lowercase-string`)
instead of throwing `InvalidDocblock: Intersection types must be all
objects`. This mirrors the runtime narrowing in `Type::intersectAtomicTypes`
and unblocks Laravel's `Illuminate\Support\Str::lower()` return type.

The collapse uses a positive allowlist (`TNonEmptyString`, `TLowercaseString`,
`TNonspecificLiteralString` and their subclasses) and exact-class matching
on the known narrowing rules so that subclasses like `TNumericString` or
`TNonFalsyString` paired with `TLowercaseString` fall through to rejection
rather than silently dropping their extra constraints.

Explicit handlers cover two class-hierarchy quirks:
- `TNonEmptyLowercaseString` extends `TNonEmptyString` but not
  `TLowercaseString`.
- `TNonEmptyNonspecificLiteralString` extends `TNonspecificLiteralString`
  but not `TNonEmptyString`.

Fixes vimeo#11821.
@psalm-github-bot

Copy link
Copy Markdown

I found these snippets:

https://psalm.dev/r/b51da730d4


/**
 * @param string $value
 * @return ($value is '' ? '' : non-empty-string&lowercase-string)
 */
function lower(string $value): string
{
    return strtolower($value);
}

/** @return non-empty-string&lowercase-string */
function ret(): string
{
    return 'hello';
}
Psalm output (using commit 7e751c0):

ERROR: InvalidDocblock - 7:1 - Intersection types must be all objects, Psalm\Type\Atomic\TNonEmptyString provided in docblock for lower

ERROR: MissingPureAnnotation - 7:10 - lower must be marked @psalm-pure to aid security analysis, run with --alter --issues=MissingPureAnnotation to fix this

ERROR: InvalidDocblock - 13:1 - Intersection types must be all objects, Psalm\Type\Atomic\TNonEmptyString provided in docblock for ret

ERROR: MissingPureAnnotation - 13:10 - ret must be marked @psalm-pure to aid security analysis, run with --alter --issues=MissingPureAnnotation to fix this

@alies-dev
alies-dev marked this pull request as ready for review April 22, 2026 10:00

@cursor cursor Bot 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 0290de1. Configure here.

Comment thread tests/TypeParseTest.php
@alies-dev alies-dev changed the title Accept string pseudo-type intersections in docblocks (#11821) [6.x] Accept string pseudo-type intersections in docblocks (#11821) May 6, 2026
@alies-dev alies-dev changed the title [6.x] Accept string pseudo-type intersections in docblocks (#11821) [6.x] Accept string pseudo-type intersections in docblocks May 6, 2026
@danog danog added the release:feature The PR will be included in 'Features' section of the release notes label Sep 27, 2026
@danog
danog merged commit c4c926b into vimeo:6.x Sep 27, 2026
59 of 61 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:feature The PR will be included in 'Features' section of the release notes

Projects

None yet

2 participants