Skip to content

Initial implementation of unsealed array shapes#5501

Draft
ondrejmirtes wants to merge 34 commits into2.2.xfrom
unsealed
Draft

Initial implementation of unsealed array shapes#5501
ondrejmirtes wants to merge 34 commits into2.2.xfrom
unsealed

Conversation

@ondrejmirtes
Copy link
Copy Markdown
Member

@ondrejmirtes ondrejmirtes commented Apr 21, 2026

Array shapes like array{a: int} in PHPDocs are only sealed in Bleeding Edge.

Without Bleeding edge, the goal is to match the current flawed behaviour as close as possible.

Closes phpstan/phpstan#13565
Closes phpstan/phpstan#8438
Closes phpstan/phpstan#11494
Closes phpstan/phpstan#12110
Closes phpstan/phpstan#14032

@phpstan-bot
Copy link
Copy Markdown
Collaborator

You've opened the pull request against the latest branch 2.2.x. PHPStan 2.2 is not going to be released for months. If your code is relevant on 2.1.x and you want it to be released sooner, please rebase your pull request and change its target to 2.1.x.

@mnapoli
Copy link
Copy Markdown
Contributor

mnapoli commented Apr 23, 2026

Could you explain why unsealed arrays are "flawed"?
Unsealed sounds to me like the expected behavior, just like interfaces allow for implementation with extra methods.

@ondrejmirtes
Copy link
Copy Markdown
Member Author

@mnapoli There are two meanings of "flawed" I'm referring to.

  1. Allowing extra keys passed into array{a: 1} type means that foreach ($a as $v) of this type is going to lead to imprecise analysis and unsafe code. The analyser would expect only the declared values of $v but in fact any type can occur there, leading to crashing or other unexpected behaviour of your code. Also, a type like array{a: 1, b?: 2} would accept array{a: 1, c: 2}, leading to silencing errors about typos - you meant to pass b but in fact you passed c. See also unsound assignability between unsealed array shapes with overlapping optional keys. phpstan#13565.
  2. PHPStan currently treats array{a: 1} in a flawed and inconsistent way. It accepts extra keys (but not if it's an empty array{}), but count($a) only counts the exact number of declared keys and doesn't account for the possibility of extra keys being passed in.

So this PR is trying to address these problems. With bleeding edge enabled, array{a: 1} will not accept extra keys. Both sealed and unsealed variants will no longer lie about how big the array might be.

@ondrejmirtes
Copy link
Copy Markdown
Member Author

Also, a big advantage of these changes is that array shape intersections are now possible! array{a: 1}&array{b: 2} does not make sense and is not currently supported by PHPStan, but: array{a: 1, ...}&array{b: 2, ...} makes a ton of sense and will lead to an array shape with both a and b keys!

@shaedrich
Copy link
Copy Markdown

This can also be incredibly helpful with sealed arrays when they are defined elsewhere. Imagine, you have @phpstan-type in two different files and you then want to have the intersection of those in a third file, which currently is not possible, leading to duplication as you always have to implicitly define this

@mnapoli
Copy link
Copy Markdown
Contributor

mnapoli commented Apr 23, 2026

Thanks for the details, I see!

@ondrejmirtes ondrejmirtes force-pushed the unsealed branch 7 times, most recently from a61b016 to f61eb22 Compare April 28, 2026 12:45
ondrejmirtes and others added 19 commits April 30, 2026 10:39
`TypeCombinator::intersect` rebuilds the constant-array side from scratch
via `ConstantArrayTypeBuilder::createEmpty()` whenever the other side is
a non-constant `ArrayType` (or when the maybe-unsealed branch fires). The
fresh builder is sealed, so `array{k: int, ...} & array<…>` silently
collapsed to a sealed `array{k: int}` — losing the openness the user
explicitly wrote in the source shape.

When the source `ConstantArrayType` is unsealed, copy its unsealed extras
onto the new builder, intersecting key/value with the other side's
iterable key/value so the open part keeps both sides' refinements. If
either side of the intersected extras becomes `never`, leave the new
shape sealed.

Update the bug-3931 fixture and two `TypeCombinatorTest` data sets to
reflect the now-preserved unsealed marker on the result.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bug-7963: loosen `@phpstan-return` to `array<int, non-empty-list<mixed>>`
to match the actual heterogeneous list-of-lists return shape — the prior
shape required positional `string`/`array<string, mixed>` types that no
union member satisfies.

bug-13978: PHPStan checks `@param-out` at every mutation, so the
intermediate state with both `key1` and `key2` (between `$item['key2'] =`
and `unset($item['key1'])`) needs to be in the union too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The two-stage collapse merged from 2.1.x preserves the per-position
record shape on unsealed too (the unsealed-types passes in
reduceArrays absorb same-signature variants before the list-collapse,
and the list-collapse now skips when every variant shares one
signature). The earlier "Fix tests: bug-7963, bug-13978" commit's
loosening of this @phpstan-return is therefore obsolete on unsealed —
revert that one part to match the sealed form already on 2.2.x.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment