Skip to content

Echo goes brrrrr.

PHP-flavoured syntax, statically typed, compiled to one native binary.

the first question everybody asks

It will not run
your PHP.

Echo borrows PHP's syntax because you can already read it, not because it is trying to be compatible with it. It never will be.

Paste a PHP file into echoc and you get a wall of diagnostics. That is working as intended.

legacy.php
$things = ['a', 1, null, 3.5];

foreach ($things as $t) {
    echo gettype($t);
}

↓  echoc build legacy.php

The function 'gettype' could not be found

Invalid type conversion: cannot assign 'int32' to 'string'

cannot assign null to 'string' - add '?' to its type if it may be absent

Invalid type conversion: cannot assign 'float64' to 'string'

4 errors, nothing was compiled. Three of them are on line one.

the language, quickly

This is what you write.

A file is a program. Every name has one type. The rest is the stuff I wanted when I sat down to actually write something.

One type, forever

The type is decided at the declaration and cannot change. Write it, or let the initializer say it. Either way, that is the type now.

$count = 3;              // int32
string $name = "Echo";

echo "{$name}: {$count}";

$count = "three";        // will not compile

No main. The file is the program. More in Types.

guard unwraps

A T? is a value that might not be there. Nesting if ($x != null) until the pyramid is taller than the function is the usual answer. guard declares a real T into the enclosing scope instead.

function lookup(int32 $key) : int32?
{
    if ($key > 0) {
        return $key * 2;
    }
    return null;
}

function doubled(int32 $key) : int32
{
    int32 $value = guard lookup($key) else {
        return -1;
    }

    return $value;
}

echo doubled(3);        // 6

The else has to leave, which is what makes the line after it safe. An ordinary if ($x != null) does not narrow the type. Only guard does.

Structs are values

There is no new. You call the type. A struct lives where you put it. A local lives on the stack, and assigning it copies it.

struct Point
{
    float64 $x;
    float64 $y;
}

$a = Point(3.0, 4.0);
$b = $a;            // a copy
$b->x = 10.0;

echo $a->x;         // still 3

Nothing is allocated and nothing is reference counted. Members are reached with ->, always, including your own.

Classes are the other half

Same declaration syntax, opposite behaviour. A class lives on the heap and is reference counted, so assigning one shares it.

class Account
{
    private string $owner;
    private int64 $balance;

    constructor(string $owner, int64 $opening)
    {
        $this->owner = $owner;
        $this->balance = $opening;
    }

    function deposit(int64 $amount) : void
    {
        $this->balance = $this->balance + $amount;
    }

    const function balance() : int64
    {
        return $this->balance;
    }
}

$a = Account("Mario", 100);
$b = $a;            // not a copy. same object, one more owner
$b->deposit(50);

echo $a->balance(); // 150

STRUCT

One owner, and a copy.

CLASS

Many owners, one shared object.

Pick per type, at the declaration, and every use site follows from it.

match is an expression

An enum can carry data per case. match reads which one you are holding, and it has to cover all of them. Leave a case out and it will not compile.

enum Distance
{
    case meters(int32 $v);
    case miles(int32 $v);
}

function to_meters(Distance $d) : int32
{
    return match ($d) {
        Distance::meters($v) => $v,
        Distance::miles($v) => $v * 1609,
    };
}

echo to_meters(Distance::miles(2));    // 3218

Add a fourth case a year from now and the compiler walks you around every match.

Operators are declarations

You can overload the built-in ones for your own types, and declare entirely new ones with their own precedence. Suffix operators are my favourite bit: they turn a number into a typed unit.

struct Length
{
    uint64 $millimeters;
}

operator (Length $a) + (Length $b) : Length
{
    return Length($a->millimeters + $b->millimeters);
}

operator (uint64 $a)mm : Length { return Length($a); }
operator (uint64 $a)cm : Length { return Length($a * 10); }
operator (uint64 $a)m  : Length { return Length($a * 1000); }

$distance = 1m + 50cm + 500mm;
echo $distance->millimeters;    // 2000

The compiler knows nothing about .. or 1m. Both are ordinary declarations you could have written. Operators →

and then this

The syntax.

The tour is the bones. These are the things that make a file nice to write.

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);        // 42

function<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 →

the memory model

Every value has exactly one owner.

When the owner goes out of scope, the value is destroyed. You can hand ownership to somebody else, and that is called a move. Here is the part I like: the call site has to agree to the mv too.

GIVE IT AWAY
function consume(mv array<int32> $xs) : int32
{
    return $xs->count();
}

echo consume(mv $nums);     // 3
echo $nums->count();        // error: '$nums' has been moved out of.
JUST LET IT LOOK
function total(const array<int32>& $xs) : int32
{
    // a read-only borrow
}

echo total($nums);          // 6
echo $nums->count();        // 3, still yours

A function signature cannot quietly eat something you thought you still had. Every place a value stops being yours is spelled out, in your own source. How ownership works →

Run it, or ship it

$ echoc run hello.eco

Straight to output. The program is built in memory and executed, and your directory looks exactly the way it did before. Debug by default, so assert and the runtime checks are still watching you.

$ echoc build -o hello hello.eco

A binary you can hand to somebody. Release by default: the checks come out, the optimizer goes in, and there is nothing to install on the far end.

The rest of the command line →

let's be honest for a moment

Echo is a hobby. Please do not run your payroll on it.

  • holes in the type system
  • a tiny standard library
  • the compiler probably has bugs

None of it is hidden and none of it is a surprise to me. The full list of what is missing, broken, or quietly wrong is a page of this documentation, and it is the one page I keep most current.

Still here?

Then you are exactly the kind of person this is for. One command, one binary, no runtime to install afterwards.

macOS on Apple Silicon and Linux on x86_64 have prebuilt binaries; anything else builds from source.