From b3ead90bdfe717facc34ce0c8fcb83dbde169416 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 15:54:47 +0200 Subject: [PATCH] Extract `Type::truncateListToSize()` from `TypeSpecifier` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `specifyTypesForCountFuncCall` had ~100 lines of inline shape-specific narrowing (rebuild as N-element list, build with required prefix + optional middle, probe `hasOffsetValueType` for unbounded max, intersect with `HasOffsetValueType` accessories for non-CAT lists, plus several `LIMIT` bail-outs that returned the original array). The five branches were interleaved with the same outer `count()`-call / size-superType filters, making the per-shape logic hard to read. Push the per-list rebuild into a new `Type::truncateListToSize(Type $sizeType): Type`: - `ConstantArrayType`: required prefix `[0, min)`, optional middle `[min, max)` when `max` is set, or probe explicit offsets via `hasOffsetValueType` until `no` when `max` is unbounded. - `ArrayType`: same prefix/middle for bounded ranges; for unbounded `max`, intersect with `HasOffsetValueType` accessories. - Non-array types (`NonArrayTypeTrait`, `MaybeArrayTypeTrait`): return `ErrorType`. - `LateResolvableTypeTrait`: delegate to the resolved type. - `UnionType` / `IntersectionType` / `StaticType`: dispatch. - `NeverType` / `MixedType` / accessory types: identity-ish — the accessories represent metadata orthogonal to size, so the narrowing flows through `IntersectionType`'s dispatcher unchanged. The method is named for its actual contract: each implementation assumes a list shape. The call site (`TypeSpecifier`) is responsible for gating on outer list-ness — a CAT inside a `non-empty-list` intersection may have its own `isList()` weakened to `Maybe` even though the aggregate is definitely a list. Letting the call site decide preserves the original behavior exactly. A private static helper `ConstantArrayType::extractTruncateListBounds()` peels `[min, max]` out of either a `ConstantIntegerType` (`[N, N]`) or an `IntegerRangeType`, sparing both implementations the same two-line shape check. Behavior preserved: full test suite green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Analyser/TypeSpecifier.php | 106 ++---------------- src/Type/Accessory/AccessoryArrayListType.php | 7 ++ src/Type/Accessory/HasOffsetType.php | 6 + src/Type/Accessory/HasOffsetValueType.php | 7 ++ src/Type/Accessory/NonEmptyArrayType.php | 7 ++ src/Type/Accessory/OversizedArrayType.php | 5 + src/Type/ArrayType.php | 70 ++++++++++++ src/Type/Constant/ConstantArrayType.php | 93 +++++++++++++++ src/Type/IntersectionType.php | 5 + src/Type/MixedType.php | 9 ++ src/Type/NeverType.php | 5 + src/Type/StaticType.php | 5 + src/Type/Traits/LateResolvableTypeTrait.php | 5 + src/Type/Traits/MaybeArrayTypeTrait.php | 5 + src/Type/Traits/NonArrayTypeTrait.php | 5 + src/Type/Type.php | 17 +++ src/Type/UnionType.php | 5 + 17 files changed, 265 insertions(+), 97 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 095a8bb8de0..a945627ef64 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -49,7 +49,6 @@ use PHPStan\Type\BooleanType; use PHPStan\Type\ConditionalTypeForParameter; use PHPStan\Type\Constant\ConstantArrayType; -use PHPStan\Type\Constant\ConstantArrayTypeBuilder; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantFloatType; use PHPStan\Type\Constant\ConstantIntegerType; @@ -100,8 +99,6 @@ final class TypeSpecifier { - private const MAX_ACCESSORIES_LIMIT = 8; - private const BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH = 4; /** @var MethodTypeSpecifyingExtension[][]|null */ @@ -1432,100 +1429,15 @@ private function specifyTypesForCountFuncCall( continue; } - if ( - $sizeType instanceof ConstantIntegerType - && $sizeType->getValue() < ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT - && $isList->yes() - && $arrayType->getKeyType()->isSuperTypeOf(IntegerRangeType::fromInterval(0, $sizeType->getValue() - 1))->yes() - ) { - // turn optional offsets non-optional - $valueTypesBuilder = ConstantArrayTypeBuilder::createEmpty(); - for ($i = 0; $i < $sizeType->getValue(); $i++) { - $offsetType = new ConstantIntegerType($i); - $valueTypesBuilder->setOffsetValueType($offsetType, $arrayType->getOffsetValueType($offsetType)); - } - $resultTypes[] = $valueTypesBuilder->getArray(); - continue; - } - - if ( - $sizeType instanceof IntegerRangeType - && $sizeType->getMin() !== null - && $sizeType->getMin() < ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT - && $isList->yes() - && $arrayType->getKeyType()->isSuperTypeOf(IntegerRangeType::fromInterval(0, ($sizeType->getMax() ?? $sizeType->getMin()) - 1))->yes() - ) { - $builderData = []; - // turn optional offsets non-optional - for ($i = 0; $i < $sizeType->getMin(); $i++) { - $offsetType = new ConstantIntegerType($i); - $builderData[] = [$offsetType, $arrayType->getOffsetValueType($offsetType), false]; - } - if ($sizeType->getMax() !== null) { - if ($sizeType->getMax() - $sizeType->getMin() > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { - $resultTypes[] = $arrayType; - continue; - } - for ($i = $sizeType->getMin(); $i < $sizeType->getMax(); $i++) { - $offsetType = new ConstantIntegerType($i); - $builderData[] = [$offsetType, $arrayType->getOffsetValueType($offsetType), true]; - } - } elseif ($arrayType->isConstantArray()->yes()) { - for ($i = $sizeType->getMin();; $i++) { - $offsetType = new ConstantIntegerType($i); - $hasOffset = $arrayType->hasOffsetValueType($offsetType); - if ($hasOffset->no()) { - break; - } - $builderData[] = [$offsetType, $arrayType->getOffsetValueType($offsetType), !$hasOffset->yes()]; - } - } else { - $intersection = []; - $intersection[] = $arrayType; - $intersection[] = new NonEmptyArrayType(); - - $zero = new ConstantIntegerType(0); - $i = 0; - foreach ($builderData as [$offsetType, $valueType]) { - // non-empty-list already implies the offset 0 - if ($zero->isSuperTypeOf($offsetType)->yes()) { - continue; - } - - if ($i > self::MAX_ACCESSORIES_LIMIT) { - break; - } - - $intersection[] = new HasOffsetValueType($offsetType, $valueType); - $i++; - } - - $resultTypes[] = TypeCombinator::intersect(...$intersection); - continue; - } - - if (count($builderData) > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { - $resultTypes[] = $arrayType; - continue; - } - - $builder = ConstantArrayTypeBuilder::createEmpty(); - foreach ($builderData as [$offsetType, $valueType, $optional]) { - $builder->setOffsetValueType($offsetType, $valueType, $optional); - } - - $builtArray = $builder->getArray(); - if ($isList->yes() && !$builder->isList()) { - $constantArrays = $builtArray->getConstantArrays(); - if (count($constantArrays) === 1) { - $builtArray = $constantArrays[0]->makeList(); - } - } - $resultTypes[] = $builtArray; - continue; - } - - $resultTypes[] = TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); + // `truncateListToSize` rebuilds the inner array as a list shape + // — that's only sound when the *outer* type is definitely a + // list. The inner array alone may have `isList()` answer `Maybe` + // (e.g. `ArrayType, T>` inside a + // `non-empty-list` intersection), so the gate has to live + // here, not on the per-array method. + $resultTypes[] = $isList->yes() + ? $arrayType->truncateListToSize($sizeType) + : TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); } if ($context->truthy() && $isConstantArray->yes() && $isList->yes()) { diff --git a/src/Type/Accessory/AccessoryArrayListType.php b/src/Type/Accessory/AccessoryArrayListType.php index fd9a988b121..98dc1b0773c 100644 --- a/src/Type/Accessory/AccessoryArrayListType.php +++ b/src/Type/Accessory/AccessoryArrayListType.php @@ -261,6 +261,13 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return $this; } + public function truncateListToSize(Type $sizeType): Type + { + // List-ness survives a count narrowing — the resulting array is + // still a list, just of a constrained size. + return $this; + } + public function makeListMaybe(): Type { // This accessory is the list assertion itself; weakening the diff --git a/src/Type/Accessory/HasOffsetType.php b/src/Type/Accessory/HasOffsetType.php index 5cdf87e7c11..ec1b3e7f4bc 100644 --- a/src/Type/Accessory/HasOffsetType.php +++ b/src/Type/Accessory/HasOffsetType.php @@ -225,6 +225,12 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return new MixedType(); } + public function truncateListToSize(Type $sizeType): Type + { + // Having a specific offset is independent of the array's size bound. + return $this; + } + public function makeListMaybe(): Type { // Having an offset doesn't conflict with list-being-maybe. diff --git a/src/Type/Accessory/HasOffsetValueType.php b/src/Type/Accessory/HasOffsetValueType.php index 15e477cdf3c..e828e91195b 100644 --- a/src/Type/Accessory/HasOffsetValueType.php +++ b/src/Type/Accessory/HasOffsetValueType.php @@ -314,6 +314,13 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return new MixedType(); } + public function truncateListToSize(Type $sizeType): Type + { + // `HasOffsetValueType` is metadata about a specific key — independent + // of the array's overall size constraint. + return $this; + } + public function makeListMaybe(): Type { // Knowing a specific offset/value is independent of list-ness. diff --git a/src/Type/Accessory/NonEmptyArrayType.php b/src/Type/Accessory/NonEmptyArrayType.php index a7520897dc7..3d65af278b9 100644 --- a/src/Type/Accessory/NonEmptyArrayType.php +++ b/src/Type/Accessory/NonEmptyArrayType.php @@ -248,6 +248,13 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return new MixedType(); } + public function truncateListToSize(Type $sizeType): Type + { + // The accessory only asserts "this array is non-empty" — truncating + // to a positive size leaves that property in place. + return $this; + } + public function makeListMaybe(): Type { // Non-emptiness is independent of list-ness; weaken-list keeps it. diff --git a/src/Type/Accessory/OversizedArrayType.php b/src/Type/Accessory/OversizedArrayType.php index df3210cf9cc..2e1a04bf01b 100644 --- a/src/Type/Accessory/OversizedArrayType.php +++ b/src/Type/Accessory/OversizedArrayType.php @@ -225,6 +225,11 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return $this; } + public function truncateListToSize(Type $sizeType): Type + { + return $this; + } + public function makeListMaybe(): Type { return $this; diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index 978e9d6ca87..ebed9eedc2b 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -55,6 +55,8 @@ class ArrayType implements Type use UndecidedComparisonTypeTrait; use NonGeneralizableTypeTrait; + private const TRUNCATE_ACCESSORIES_LIMIT = 8; + private Type $keyType; private ?TrinaryLogic $isList = null; @@ -589,6 +591,74 @@ public function makeListMaybe(): Type return $this; } + public function truncateListToSize(Type $sizeType): Type + { + [$min, $max] = ConstantArrayType::extractTruncateListBounds($sizeType); + + // `isList()` is deliberately NOT checked here — see the matching + // note on `ConstantArrayType::truncateListToSize`. The call site + // has already established outer list-ness. + if ( + $min === null + || $min >= ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT + || !$this->getKeyType()->isSuperTypeOf(IntegerRangeType::fromInterval(0, ($max ?? $min) - 1))->yes() + ) { + return TypeCombinator::intersect($this, new NonEmptyArrayType()); + } + + if ($max !== null) { + // Bounded range — `ArrayType` doesn't carry per-offset types, so + // rebuild via the same CAT builder logic as `ConstantArrayType`. + // The values come from `$this->getOffsetValueType()` (which on a + // general `ArrayType` collapses to the iterable value type). + if ($max - $min > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { + return TypeCombinator::intersect($this, new NonEmptyArrayType()); + } + + $builder = ConstantArrayTypeBuilder::createEmpty(); + for ($i = 0; $i < $min; $i++) { + $offsetType = new ConstantIntegerType($i); + $builder->setOffsetValueType($offsetType, $this->getOffsetValueType($offsetType), false); + } + for ($i = $min; $i < $max; $i++) { + $offsetType = new ConstantIntegerType($i); + $builder->setOffsetValueType($offsetType, $this->getOffsetValueType($offsetType), true); + } + + $builtArray = $builder->getArray(); + if (!$builder->isList()) { + $constantArrays = $builtArray->getConstantArrays(); + if (count($constantArrays) === 1) { + $builtArray = $constantArrays[0]->makeList(); + } + } + + return $builtArray; + } + + // Unbounded max on a general `ArrayType` list: we can't enumerate the + // trailing entries, so anchor the lower bound with + // `HasOffsetValueType` accessories (skipping offset 0 — already + // implied by `NonEmptyArrayType`). + $intersection = [$this, new NonEmptyArrayType()]; + $zero = new ConstantIntegerType(0); + $added = 0; + for ($i = 0; $i < $min; $i++) { + $offsetType = new ConstantIntegerType($i); + if ($zero->isSuperTypeOf($offsetType)->yes()) { + continue; + } + if ($added > self::TRUNCATE_ACCESSORIES_LIMIT) { + break; + } + + $intersection[] = new HasOffsetValueType($offsetType, $this->getOffsetValueType($offsetType)); + $added++; + } + + return TypeCombinator::intersect(...$intersection); + } + public function mapValueType(callable $cb): Type { return $this->withTypes($this->keyType, $cb($this->getItemType())); diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index e0ee7290b4a..8850f7f45a2 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1292,6 +1292,99 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return TypeCombinator::union(...$types); } + public function truncateListToSize(Type $sizeType): Type + { + [$min, $max] = self::extractTruncateListBounds($sizeType); + + // `getMin() === null` ↔ unbounded below; the narrowing has no anchor + // to start from. Also bail out when the required prefix would exceed + // the array-shape limit — we can't enumerate that many keys. + // `isList()` is intentionally NOT checked here: the call site + // (`TypeSpecifier`) only invokes this when the *outer* aggregate is + // already a list, but a CAT inside a `non-empty-list` intersection + // may have its own `isList()` weakened to `Maybe`. + if ( + $min === null + || $min >= ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT + || !$this->getKeyType()->isSuperTypeOf(IntegerRangeType::fromInterval(0, ($max ?? $min) - 1))->yes() + ) { + return TypeCombinator::intersect($this, new NonEmptyArrayType()); + } + + // Required prefix `[0, $min)`: every value definitely present. + $builderData = []; + for ($i = 0; $i < $min; $i++) { + $offsetType = new ConstantIntegerType($i); + $builderData[] = [$offsetType, $this->getOffsetValueType($offsetType), false]; + } + + if ($max !== null) { + // Optional middle `[$min, $max)`. + if ($max - $min > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { + return TypeCombinator::intersect($this, new NonEmptyArrayType()); + } + for ($i = $min; $i < $max; $i++) { + $offsetType = new ConstantIntegerType($i); + $builderData[] = [$offsetType, $this->getOffsetValueType($offsetType), true]; + } + } else { + // Unbounded max: probe explicit keys from `$min` onward until + // `hasOffsetValueType` answers `no`. Each probe contributes one + // optional (or required, when `hasOffsetValueType` is `yes`) slot. + for ($i = $min;; $i++) { + $offsetType = new ConstantIntegerType($i); + $hasOffset = $this->hasOffsetValueType($offsetType); + if ($hasOffset->no()) { + break; + } + $builderData[] = [$offsetType, $this->getOffsetValueType($offsetType), !$hasOffset->yes()]; + } + } + + if (count($builderData) > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { + return TypeCombinator::intersect($this, new NonEmptyArrayType()); + } + + $builder = ConstantArrayTypeBuilder::createEmpty(); + foreach ($builderData as [$offsetType, $valueType, $optional]) { + $builder->setOffsetValueType($offsetType, $valueType, $optional); + } + + $builtArray = $builder->getArray(); + // `setOffsetValueType` on a brand-new builder produces a list when + // the resulting offsets are sequential ints — but it may not preserve + // list-ness in every shape. Reattach it for the single-CAT case. + if (!$builder->isList()) { + $constantArrays = $builtArray->getConstantArrays(); + if (count($constantArrays) === 1) { + $builtArray = $constantArrays[0]->makeList(); + } + } + + return $builtArray; + } + + /** + * Extracts (min, max) bounds from a size type for `truncateListToSize`. + * `ConstantIntegerType(N)` → `[N, N]`. `IntegerRangeType` → + * `[$min, $max]`. Anything else returns `[null, null]` and the caller + * falls back to the non-precise path. + * + * @return array{?int, ?int} + */ + public static function extractTruncateListBounds(Type $sizeType): array + { + if ($sizeType instanceof ConstantIntegerType) { + return [$sizeType->getValue(), $sizeType->getValue()]; + } + + if ($sizeType instanceof IntegerRangeType) { + return [$sizeType->getMin(), $sizeType->getMax()]; + } + + return [null, null]; + } + public function isIterableAtLeastOnce(): TrinaryLogic { $keysCount = count($this->keyTypes); diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 56762ae30c5..e13f3e3bf4b 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -1228,6 +1228,11 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return $this->intersectTypesPreserveTemplateType(static fn (Type $type): Type => $type->spliceArray($offsetType, $lengthType, $replacementType)); } + public function truncateListToSize(Type $sizeType): Type + { + return $this->intersectTypesPreserveTemplateType(static fn (Type $type): Type => $type->truncateListToSize($sizeType)); + } + public function makeListMaybe(): Type { return $this->intersectTypes(static fn (Type $type): Type => $type->makeListMaybe()); diff --git a/src/Type/MixedType.php b/src/Type/MixedType.php index 8fee88984df..34c0bcbc40f 100644 --- a/src/Type/MixedType.php +++ b/src/Type/MixedType.php @@ -306,6 +306,15 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return new ArrayType(new MixedType($this->isExplicitMixed), new MixedType($this->isExplicitMixed)); } + public function truncateListToSize(Type $sizeType): Type + { + if ($this->isArray()->no()) { + return new ErrorType(); + } + + return $this; + } + public function makeListMaybe(): Type { // `mixed` doesn't track list-ness; nothing to weaken. diff --git a/src/Type/NeverType.php b/src/Type/NeverType.php index c73d5813a62..5639fa5168d 100644 --- a/src/Type/NeverType.php +++ b/src/Type/NeverType.php @@ -385,6 +385,11 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return new NeverType(); } + public function truncateListToSize(Type $sizeType): Type + { + return new NeverType(); + } + public function makeListMaybe(): Type { return new NeverType(); diff --git a/src/Type/StaticType.php b/src/Type/StaticType.php index ff646bb5332..27bc205bdde 100644 --- a/src/Type/StaticType.php +++ b/src/Type/StaticType.php @@ -560,6 +560,11 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return $this->getStaticObjectType()->spliceArray($offsetType, $lengthType, $replacementType); } + public function truncateListToSize(Type $sizeType): Type + { + return $this->getStaticObjectType()->truncateListToSize($sizeType); + } + public function makeListMaybe(): Type { return $this->getStaticObjectType()->makeListMaybe(); diff --git a/src/Type/Traits/LateResolvableTypeTrait.php b/src/Type/Traits/LateResolvableTypeTrait.php index e5d2b583737..1bb2df4e81a 100644 --- a/src/Type/Traits/LateResolvableTypeTrait.php +++ b/src/Type/Traits/LateResolvableTypeTrait.php @@ -356,6 +356,11 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return $this->resolve()->spliceArray($offsetType, $lengthType, $replacementType); } + public function truncateListToSize(Type $sizeType): Type + { + return $this->resolve()->truncateListToSize($sizeType); + } + public function makeListMaybe(): Type { return $this->resolve()->makeListMaybe(); diff --git a/src/Type/Traits/MaybeArrayTypeTrait.php b/src/Type/Traits/MaybeArrayTypeTrait.php index 9b87c858cad..a5d27c25643 100644 --- a/src/Type/Traits/MaybeArrayTypeTrait.php +++ b/src/Type/Traits/MaybeArrayTypeTrait.php @@ -109,6 +109,11 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return new ErrorType(); } + public function truncateListToSize(Type $sizeType): Type + { + return new ErrorType(); + } + public function makeListMaybe(): Type { return $this; diff --git a/src/Type/Traits/NonArrayTypeTrait.php b/src/Type/Traits/NonArrayTypeTrait.php index eb06353c49f..46a99a92929 100644 --- a/src/Type/Traits/NonArrayTypeTrait.php +++ b/src/Type/Traits/NonArrayTypeTrait.php @@ -109,6 +109,11 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return new ErrorType(); } + public function truncateListToSize(Type $sizeType): Type + { + return new ErrorType(); + } + public function makeListMaybe(): Type { return $this; diff --git a/src/Type/Type.php b/src/Type/Type.php index 85906bcb54d..da72fb4c077 100644 --- a/src/Type/Type.php +++ b/src/Type/Type.php @@ -283,6 +283,23 @@ public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $pre /** Models array_splice() effect on the array (the modified array, not the removed portion). */ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacementType): Type; + /** + * Narrows a list-shaped array type to "size lies in $sizeType" — the + * type-system equivalent of `count($list) === N` / `count($list) >= N` + * / `count($list) in [N, M]`. Used by `TypeSpecifier` when specifying + * types for `count()` comparisons; the call site is responsible for + * gating on outer list-ness, so each implementation may assume it's + * narrowing a list shape. + * + * `$sizeType` is expected to be a `ConstantIntegerType` (exact size) or + * an `IntegerRangeType` (a min/max bound). Concrete implementations + * (`ConstantArrayType`, `ArrayType`) rebuild the array with a required + * prefix `[0, min)` and an optional middle `[min, max)` (when `max` is + * set), or extend with `HasOffsetValueType` accessories when the upper + * bound is unbounded. Non-array types return `ErrorType`. + */ + public function truncateListToSize(Type $sizeType): Type; + /** * Downgrades the list-ness of the array from `Yes` to `Maybe` (e.g. for * `asort`/`uksort`/etc. which preserve keys but break list ordering). diff --git a/src/Type/UnionType.php b/src/Type/UnionType.php index c1296d2f8f3..7c1bdaecbcd 100644 --- a/src/Type/UnionType.php +++ b/src/Type/UnionType.php @@ -907,6 +907,11 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return $this->unionTypes(static fn (Type $type): Type => $type->spliceArray($offsetType, $lengthType, $replacementType)); } + public function truncateListToSize(Type $sizeType): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->truncateListToSize($sizeType)); + } + public function makeListMaybe(): Type { return $this->unionTypes(static fn (Type $type): Type => $type->makeListMaybe());