An array holds one type
Brackets are how you write one. The type is in the declaration, and it does not change.
array<int32> $numbers = [1, 2, 3];
$numbers[] = 4;
echo $numbers->count(); // 4
echo $numbers[0]; // 1
Methods live on the array: count, push, pop. Arrays →
Interpolation is just a string
Building a string out of values is part of the literal. No format function, no append.
$name = 'Echo';
$year = 2026;
echo "{$name} is {$year}."; // interpolates
echo '{$name} is not'; // does not Double quotes interpolate. Single quotes leave the braces alone. Strings →
A closure is a value
Sometimes the thing you want to pass is another function, and it is not worth naming.
function<int32(int32)> $double = function(int32 $a) : int32 {
return $a * 2;
};
echo $double(21); // 42function<R(P...)> is an ordinary type: a variable, a parameter, a return. Capture is by value. Closures →
Tests sit next to the code
A test is a block you write in the file it is about. Every invocation except echoc test drops it before it is parsed.
test adds_up
{
assert(22 + 20 == 42);
} No framework, no separate directory, and it cannot end up in a binary by accident. Testing →
Write it once
A function that works for every type that makes sense, compiled down to a concrete copy per type you actually use. No boxing.
function largest<T : numeric>(T $a, T $b) : T
{
if ($a > $b) {
return $a;
}
return $b;
}
echo largest(3, 7); // 7
echo largest(1.5, 0.5); // 1.500000 Echo monomorphizes. largest(3, 7) is two int32s by the time it runs. Generics →
The destination names the type
Wherever the destination already said result<int32, string>, the leading dot fills in the owner.
function halve(int32 $n) : result<int32, string>
{
if ($n % 2 != 0) {
return .error('odd');
}
return .ok($n / 2);
}
echo halve(10)->or(-1); // 5 A return type is a destination. So is a declared variable. Results →
Same name, different arguments
Several functions can share a name as long as the parameters differ. Picked at compile time.
function describe(int32 $v) : void { echo 1; }
function describe(string $v) : void { echo 2; }
describe(1); // 1
describe("x"); // 2 The return type is not part of the signature. Two functions that differ only in what they return are a duplicate, not an overload. Functions →
Absence has to be written
A T? is a value that might not be there. ?? supplies a fallback. ?-> reaches through and stops at the first null.
class Node
{
int32 $tag;
Node? $next;
}
int32? $n = 8;
echo $n ?? -1; // 8
Node? $head = null;
echo $head?->next?->tag ?? -1; // -1 Neither one unwraps. For that, guard. Nullability →
.. is not syntax
foreach walks anything that says it can be iterated. A range is one of those, and .. is an ordinary operator from the standard library.
foreach (0 .. 3 as $i) {
echo $i; // 0 1 2
} The compiler knows nothing about 0 .. 3. You could have written it. Ranges →
A word can be an operator
You are not limited to the symbols the language ships. A word works, and you pick where it sits in the precedence table.
operator (float64 $a) avg (float64 $b) : float64
{
return ($a + $b) / 2.0;
}
echo 10.0 avg 20.0; // 15.000000 Declaring avg as an operator does not stop you declaring a function called avg. They live in different worlds. Operators →
const if happens first
A normal if runs at runtime. const if happens before that: the compiler picks an arm and the other one never becomes part of the program.
function describe<T>() : void
{
const if (mem::needs_destruction<T>()) {
echo "owns something";
} else {
echo "plain data";
}
}
describe<int32>(); // plain data The losing arm is discarded before type checking, so it may contain code that would not even compile for the current type. Control flow →
dprint dumps the shape
When you just want to see a value, dprint dumps it with its shape and its types. No -g.
struct Point
{
float64 $x;
float64 $y;
}
dprint(Point(3.0, 4.0));
// [Point] { $x = 3, $y = 4 } It is a compile-time expansion rather than a runtime walk, so a static value costs nothing. Debugging →