Table of Contents

PHP RFC: Native Immutable Collections — "vec", "set" and "tuple"

Introduction

PHP's array is a single, mutable type that serves as an ordered list, an associative map, a record and a set at once. That versatility means an array type declaration conveys almost nothing: not the element type, not whether the value is a sequence or a map, not whether it is a fixed-arity record, and not whether it may be mutated after construction.

This RFC proposes three native, immutable, value-typed collections:

$ids   = vec[int]{1, 2, 3};
$perms = set[string]{"read", "write", "read"};   // {"read", "write"}
$pair  = tuple[int, string]{200, "OK"};

Each is a first-class runtime value with its own type tag — not an object and not an array with conventions. Each is immutable (length, order and contents are fixed at construction) and carries a declared element or member type that is validated at construction and is part of the value's runtime identity, so vec[int]{1} and vec[float]{1.0} are different types and a vec[int] is never interchangeable with a vec[float].

Motivation

Proposal

This RFC adds three collection kinds — vec, set, tuple — as native immutable value types, with:

map, shape, functional/bulk methods, contextual literal inference, a general update expression, and a global count() overload are not part of this RFC; see Future scope.

Syntax

A collection type is written with a compound head token — the kind name immediately followed by [, with no whitespace between them:

collection_type    : head '[' type_list ']'
collection_literal : head '[' type_list ']' '{' element_list? '}'
head               : 'vec' | 'set' | 'tuple'
type_list          : type (',' type)*                  (no trailing comma)
element_list       : expr (',' expr)* ','?             (trailing comma allowed)

vec, set and tuple are not reserved words. Only the adjacent two-token sequence name[ in type/expression position is special, so existing code keeps working:

function vec(int $n): void {}   // function named vec
class set {}                     // class named set
const tuple = 7;                 // constant named tuple
$o->vec[0];                      // property access then subscript
Foo::vec[0];                     // class-constant access then subscript

Type declarations

vec[T], set[T] and tuple[A, B, …] may be used anywhere an ordinary type may be used — parameters, return types, and properties (including promoted and readonly properties, and interface/abstract method signatures). Ordinary PHP visibility and readonly rules apply unchanged.

function sum(vec[int] $values): int { /* … */ }
function headers(): vec[string] { /* … */ }
 
final class Response
{
    public function __construct(
        public readonly tuple[int, string] $status,
        public readonly set[string]        $varyHeaders,
    ) {}
}

Concrete collection types

At the declaration level the member-type grammar is permissive: it accepts builtin types, class names and nested collections. Not every member type a declaration accepts can hold a value, however; that narrower question is covered under Construction and validation.

Collections nest without special syntax, because a member type is an ordinary type:

function matrix(): vec[vec[int]] { /* … */ }
function pairs(): vec[tuple[int, string]] { /* … */ }

Bare collection types — ''vec[]'', ''set[]'', ''tuple[]''

Empty brackets give a bare collection type meaning *any element type of that kind* — the top of that kind's hierarchy:

function rows(vec[] $any): int { return $any->count; }   // any vec accepted
rows(vec[int]{1, 2});     // ok
rows(vec[string]{"a"});   // ok
rows(set[int]{1});        // TypeError: … must be of type vec[], set[int] given

Nullability, nesting and variance

Nullability uses the ordinary prefix ? form (?vec[Row]). A collection type may not be a member of a union or intersection (bare forms included); this is a compile-time error naming the fix:

function f(vec[int]|null $x) {}
// Fatal error: Collection type cannot be part of a union type;
// write ?vec[...] for a nullable collection

Concrete collection types are invariant in their members: vec[Dog] is not a vec[Animal] (nor the reverse), and vec[int] is not vec[float]. The only subtyping is the bare form: a subtype may narrow a vec[] return to a concrete vec[int] and widen a vec[int] parameter to vec[].

Construction and validation

A literal evaluates every element left to right, then validates each against the declared member type; publishing the value is a single final step, so no partially built value is ever observable.

vec[int]{1, 2, 3};
tuple[int, string]{200, "OK"};
set[int]{1, 2, 2, 3};        // → {1, 2, 3}

Constructible element types. A type accepted as a declaration is not automatically one a value may hold. A value may hold: the leaf scalars int / float / string / bool / array, a single class/interface name, and a nested constructible collection (all three kinds, including set[vec[int]]). Every other accepted member type — ?int, int|string, A&B, mixed, object, callable, null, false — parses and type-checks but cannot hold a value, and constructing such a literal fails before any element is evaluated:

vec[?int]{1};   // TypeError: Cannot create a value of type vec[?int]
vec[mixed]{1};  // TypeError: Cannot create a value of type vec[mixed]

vec[?int] is thus a legal type even though vec[?int]{…} is a runtime error.

Validation is strict and coercion-free, independent of strict_types — no scalar coercion, and a class member accepts subtypes only. The error names the position, the collection type, and the expected vs. offending type:

vec[int]{1, "2"};        // TypeError: Element 1 of vec[int] must be of type int, string given
vec[float]{1};           // TypeError: Element 0 of vec[float] must be of type float, int given
vec[Animal]{new Dog()};  // ok (Dog is an Animal);  vec[Dog]{new Animal()} is a TypeError

Failure atomicity. All elements are evaluated before validation and publication; if an element expression throws, no value is produced and the assignment target is untouched. Side effects of already-evaluated elements are retained, because PHP cannot unwind them.

A collection literal is not permitted in a constant expression (const, class constant, parameter/property default, enum case, attribute argument); these fail at compile time with *“Collection literals are not allowed in constant expressions.”* Ordinary runtime initializers (e.g. static $x = vec[int]{…}) are unaffected.

Common semantics

Immutability

A collection cannot be modified in place. $v[0] = …, $v[] = … and unset($v[0]) are not operations on a collection. Immutability is shallow: a mutable object stored as an element stays mutable, but the collection's length, order and slots are fixed.

Identity and comparison

=== / !== is recursive structural value identity: two collections are identical iff they have the same type (kind + member types) and strictly identical elementsvec/tuple positionally, set order-insensitively — recursing into nested collections. Elements compare with ordinary ===.

vec[int]{1, 2} === vec[int]{1, 2};   // true
vec[int]{1, 2} === vec[int]{2, 1};   // false  (vec/tuple are positional)
set[int]{1, 2} === set[int]{2, 1};   // true   (set identity is order-insensitive)
vec[int]{1}    === vec[float]{1.0};  // false  (different type)
vec[int]{1, 2} === [1, 2];           // false  (never === a non-collection)

Loose and ordering comparisons — ==, !=, <, <=, >, >=, <=>, sorting, and non-strict in_arraythrow `TypeError: Cannot compare collection values`. A collection has a well-defined equality but no natural ordering; use ===. (Strict !== null / !== false / !== true behave normally.)

Truthiness and conversions

References and contained objects

A collection is a value, not an object, so clone does not apply. A collection cannot hold a PHP reference as an independently mutable slot: a reference element is read by value. There is no implicit conversion between array and any collection kind in either direction.

Collection kinds

''vec''

vec[T] is an ordered, densely indexed (from 0) sequence with a single element type T. A vec element may itself be a constructible collection.

$v      = vec[int]{10, 20, 30};
$nested = vec[vec[int]]{ vec[int]{1, 2}, vec[int]{3} };

''set''

set[T] is a collection of distinct T. Distinctness is decided by the same structural ===. Duplicates are silently dropped (that is set semantics, not an error) and the first occurrence is kept; iteration visits values in first-occurrence order, but set identity is order-insensitive, so iteration order is not part of the value.

set[int]{1, 2, 2, 3, 1};    // {1, 2, 3}
set[vec[int]]{ vec[int]{1, 2}, vec[int]{1, 2}, vec[int]{3} };  // 2 distinct members

''tuple''

tuple[A, B, …] is a fixed-arity record with a declared type per position; element i is validated against member i. The element count must equal the declared arity exactly, and — because arity is statically known — a mismatch is a compile-time error:

tuple[int, string]{200, "OK"};   // ok
tuple[int, string]{200};          // Fatal error: Collection type tuple expects 2 elements, 1 given

Reading collections

Indexing

$v[$i] reads the element at integer position $i from a vec or tuple (read-only):

(vec[int]{10, 20, 30})[1];        // 20
(tuple[int, string]{1, "a"})[1];  // "a"

Iteration and ''iterable''

foreach iterates by value: vec/tuple yield int keys 0..n−1 in order, set yields keys over its first-occurrence order. A contained array is copy-on-write, an object keeps its identity, a nested collection stays immutable. Iterating by reference throws (Cannot iterate over vec by reference).

foreach (vec[int]{10, 20, 30} as $k => $v) { /* 0=>10 1=>20 2=>30 */ }
foreach (set[int]{3, 1, 3, 2} as $v)       { /* 3, 1, 2 */ }

Collections satisfy the iterable type: is_iterable($c) is true, an iterable parameter/return accepts a collection, and iterator_to_array() / iterator_count() work. A collection is not Traversable and not an object — it is iterable as a value, like an array.

Cardinality

Cardinality is read through two read-only intrinsic properties, ->count and ->isEmpty. They are a closed, case-sensitive set (any other name throws) and read-only (assignment / ++ / unset throw):

(vec[int]{1, 2, 3})->count;   // 3
(vec[int]{})->isEmpty;         // true
(set[int]{1, 1, 2})->count;   // 2   (after de-duplication)

The global count() function is not extended in this RFC (see Future scope).

Immutable update methods

New collections are produced from existing ones through methods; each returns a new collection and never mutates the receiver. Method dispatch does not turn the value into an object (is_object($c) stays false); the methods are chainable, first-class-callable ($c->append(...)) and reflectable.

$v = vec[int]{1, 2, 3};
$v->append(4);        // vec[int]{1,2,3,4}   ($v unchanged)
$v->prepend(0);       // vec[int]{0,1,2,3}
$v->withAt(1, 99);    // vec[int]{1,99,3}
$v->withoutAt(0);     // vec[int]{2,3}
 
$s = set[int]{1, 2, 3};
$s->with(4)->without(2);
$s->union(set[int]{3, 4, 5});     // {1,2,3,4,5}
$s->intersect(set[int]{2, 3, 9}); // {2,3}
$s->diff(set[int]{2});            // {1,3}
 
$t = tuple[int, string]{1, "a"};
$t->withAt(1, "b");   // tuple[int,string]{1,"b"}  (arity preserved)
Receiver Signature Meaning
vec[T] append(T $value): vec[T] $value added at the end
vec[T] prepend(T $value): vec[T] $value added at the front
vec[T] withAt(int $i, T $value): vec[T] index $i replaced
vec[T] withoutAt(int $i): vec[T] index $i removed (the rest close up)
tuple[…] withAt(int $i, T $value): tuple[…] position $i replaced (T = the type at $i; arity fixed)
set[T] with(T $value): set[T] a set that also contains $value
set[T] without(T $value): set[T] a set without $value
set[T] union(set[T] $other): set[T] the union of the two sets
set[T] intersect(set[T] $other): set[T] the intersection of the two sets
set[T] diff(set[T] $other): set[T] the elements in this set but not $other

Here T is notation, not PHP syntax — PHP has no generics. T denotes the receiver's element type (for tuple::withAt, the type declared at position $i), and vec[T] / set[T] / tuple[…] denotes the receiver's exact type.

tuple has no append/remove because changing arity would produce a different type. Functional/bulk methods (map, filter, …) are not part of this RFC (see Future scope).

Serialization and display

serialize() and unserialize() round-trip every kind, using one new, versionless serialize token. unserialize() rebuilds the type and revalidates every element through the same path a literal uses, so it grants no new trust: malformed input, an unknown or unconstructible kind, a bad element, or a bad count/arity all fail cleanly (unserialize() returns false). Existing serialized data is unaffected.

var_export() emits re-parseable collection-literal syntax; var_dump(), print_r() and debug_zval_dump() render the parameterised type and elements.

var_export(vec[int]{1, 2, 3});   // vec[int]{ 1, 2, 3, }   (re-parseable)

Reflection

A collection type is reflected through a dedicated ReflectionCollectionType (extending ReflectionType):

$t = (new ReflectionFunction(fn(vec[vec[string]] $x) => null))->getParameters()[0]->getType();
 
$t instanceof ReflectionCollectionType;   // true
$t->getCollectionName();                   // "vec"
(string) $t;                               // "vec[vec[string]]"
$t->allowsNull();                          // false
$t->getTypes();                            // [ ReflectionCollectionType for vec[string] ]

getCollectionName() returns the kind; getTypes() returns the member types (and the empty array [] for a bare type). getTypes() generalises to multi-member kinds.

Error behavior

Condition Error When
Element does not satisfy the member type TypeError (Element N of T must be of type …, … given) construction (runtime)
Member type has no value representation (?int, mixed, union, …) TypeError (Cannot create a value of type …) before elements (runtime)
tuple element count ≠ arity Fatal error (… expects N elements, M given) compile time
Wrong type-argument count (vec[int, string]) Fatal error (… expects 1 parameter, 2 given) compile time
Collection type in a union/intersection Fatal error compile time
Collection literal in a constant expression Fatal error compile time
Index of wrong type / out of range TypeError / ValueError runtime
Loose or ordering comparison of collections TypeError (Cannot compare collection values) runtime
foreach by reference Error (Cannot iterate over … by reference) runtime
Numeric/string conversion TypeError runtime
Update method: bad element / out-of-range index TypeError / ValueError runtime

Backward compatibility

Rejected alternatives

Future scope

The following are not proposed here and are independently decidable later. Detailed design analysis for these items is kept in separate design notes, not in this RFC.

Proposed PHP version

PHP 9.0 (8.6 is already at feature freeze).

Implementation

Implemented on the ''first-class-collections'' branch of the author's php-src fork; a pull request to php-src will follow. The engine-level design (runtime representation, descriptors and canonicalization, the construction pipeline, set de-duplication, garbage collection, the serialization wire format, and opcache/JIT integration), the current build and platform status, and the test suite are documented separately and accompany the implementation.

The implementation includes coverage for parsing, type declarations, construction, value semantics, iteration, indexing, the update methods, serialization, Reflection, and opcache/JIT.

Vote

The vote requires a two-thirds majority.

Add native immutable collection value types — vec, set and tuple — as
described in this RFC?

Secondary votes may be offered for independently removable parts (for example initial Reflection support, or the target version) if reviewers request them.


References