First of all, I’m sorry if i repeat some stuff or say something that’s obvious! Anyways…

I already wrote a shorter post about why Pie exists, why it was private for so long, yada yada.

This post is different, a more technical one! I realize I missed alot of information in my first post so I hope this one is lot more informative.

I’ll answer some of the most asked questions here, talk about the codebase itself and why I did what I did.

Pie is still experimental, the syntax can still change, and it actually did change since last time!

A small Pie program

Here is the general shape of Pie:

struct User:
    id: int
    name: string
end

fn display_name(user: &User) -> string:
    if user.name == "":
        return "anonymous"
    end

    return user.name
end

fn main() -> int:
    user: User -> new User(id: 1, name: "Ada")
    println(display_name(&user))
    return 0
end

There are a few things to notice:

  • blocks start with : and end with end
  • bindings use name: Type -> value
  • functions use fn name(args) -> ReturnType:
  • references use &T and mutable references use &mut T
  • there are no semicolons!!!!

A lot of Pie is intentionally familiar, who would use a language that exists only to be quirky, I don’t want users to spend their first hours figuring out the syntax too much :P

Binding, assignment, and so on

The syntax that started the whole thing for me was this:

name: int -> 1

The gods revealed this one line to me in a dream, that line is a binding, it just introduces a new name into the current scope, easy.

The general form is:

name: Type -> value

When the type can be inferred, simply drop the colon and the type name entirely:

name -> "Ada"
count -> 67

This keeps variable declarations incredibly clean while retaining the left-to-right binding arrow. If a name already exists in the same scope, it will throw an error.

fn main() -> int:
    mut count: int -> 0

    while count < 5:
        println(count)
        count <- count + 1
    end

    return 0
end

Compound assignment exists too (wow):

count += 1
mask &= flags
power **= 2

I completely avoid using = for assignment or mutation. That separation is important:

x: int -> 10   # introduce x (left-to-right binding)
x <- 20        # mutate x (right-to-left reassignment), if x is mutable

Mutability is explicit

Bindings are immutable by default!

name -> "Simon"
name <- "Grace" # compile error

To mutate a local, mark it with mut:

mut name -> "Simon"
name <- "Grace"

That signal matters for borrowing too:

fn rename(user: &mut User, name: string):
    user.name <- name
end

fn main() -> int:
    mut user -> new User(id: 1, name: "Simon")
    rename(&mut user, "Grace")
    println(user.name)
    return 0
end

You can’t take a mutable reference to an immutable binding btw.

Blocks use : and end

Pie does not use semantic indentation like Python, and it does not use braces like C, Rust, Go, JavaScript, Java, and so on.

A block starts with : and ends with end:

if ready:
    start()
end

This is not for no reason, I like indentation! but I don’t want indentation to define how the program runs, I think it would be annoying to deal with.

end isn’t optional, and you can do this too:

if ready: start() end

Can’t do this tho:

if ready:
    start()
# missing end

The parser will tell you about a missing end, so don’t worry.

Why not semicolons?

Because I don’t like them, they’re useless! (i know this sounds hypocritical)

The technical answer is that Pie treats newlines as statement separators.

x: int -> 1, y: int -> 2, println(x + y)

See this code? ugly and messy, why not jut put that in newlines you may ask? Not sure myself, looking back it seems not so good anymore, so we resolved this by supporting destructuring:

(x, y): (int, int) -> (1, 2)
println(x + y)

Or with type inference:

(x, y) -> (1, 2)
println(x + y)

The parser only treats a comma as a statement boundary when it isn’t inside parentheses, brackets, or braces. So these still mean what you expect:

println("x", x, "y", y)
items: list(int) -> [1, 2, 3]
scores: map(string, int) -> {"Simon": 10, "Grace": 12}

This looks small, but it matters. If commas can separate statements, the parser has to know whether it’s looking at a top-level comma or an inner comma. Pie tracks nesting while finding statement boundaries so is all good.

The Slice type system

Pie has a custom static type system. I have been calling it Slice.

That name isn’t meant to pretend I invented an entire new academic category of type theory (I wish haha). It’s a name for the particular combination I want the language to have: static types, local inference, explicit ownership, borrowing, region-aware lifetimes, algebraic data types, and a small amount of controlled dynamic escape through any :D.

The main idea of Slice is that a type in Pie should answer more than one question and be clear, it should also help answer questions like:

who owns this value?
can this value be copied?
can this reference mutate?
can this value escape this region?

I don’t think of Pie’s type system as just int, string, and User. The interesting part is how those types interact with ownership and effects at compile time, Pie, of course, still has the usual primitive types:

int       signed integer, 64-bit by default
float     floating point, 64-bit by default
string    UTF-8 text
char      character
bool      true or false
byte      8-bit byte
void      no value
any       dynamic fallback, mostly for interop/scripting edges

Width modifiers are part of the syntax:

a: int<8> -> 12
b: int<32> -> 1000
c: int<64> -> 123456
d: int<128> -> 999999999

x: float<32> -> 3.14
y: float<64> -> 3.1415926535

There is also a design for int<auto> and float<auto>:

small: int<auto> -> 2
medium: int<auto> -> 300
large: int<auto> -> 12345678901234567890

The idea is that int<auto> chooses the smallest width that fits the literal, while int stays as the default 64-bit integer.

x: int -> 72        # always the default int width (64-bit)
y: int<32> -> 73    # explicitly 32-bit
z: int<auto> -> 74  # compiler may pick a smaller width

More often than not, you dont really need to get rid of that extra memory unless you’re doing something sensitive, I didn’t wanna int to change the width just because a literal happens to be small.

Compound types look like this:

items: list(int)
table: map(string, int)
pair: (int, string)
maybe_name: ?string
result: Result(User)
callback: fn(int) -> bool

References are also part of the type system:

owned: User          # owned value
shared: &User        # shared immutable borrow
editable: &mut User  # exclusive mutable borrow
raw: *User           # raw pointer, unsafe to dereference

This matters because User, &User, &mut User, and *User describe different permissions clearly.

User owns the value. &User can read it but not mutate it. &mut User can mutate it, but only while it’s the only active borrow. *User is outside the safe reference system and needs unsafe when you actually dereference it.

So Slice isn’t just a type checker, but also a permission checker :D.

A simple example:

fn print_user(user: &User):
    println(user.name)
end

fn rename(user: &mut User, name: string):
    user.name <- name
end

Both functions receive something that points at a User, but they don’t receive the same permissions. print_user receives read access. rename receives exclusive write access.

Inference without hiding

Pie allows local inference:

name -> "Simon"
count -> 68
user -> load_user(1)?

The goal isn’t to make types disappear entirely, but to avoid repeating obvious information.

Function signatures should usually stay explicit:

fn find_user(id: int) -> Result(User):
    ...
end

APIs should be readable without opening the function body, local variables can be lighter because the compiler and nearby expression often make the type obvious.

Nominal user types, structural built-ins

User-defined structs and enums are nominal:

struct UserId:
    value: int
end

struct ProductId:
    value: int
end

Built-in containers are generic:

users: list(User)
lookup: map(string, User)
maybe: Option(User)
result: Result(User)

This gives Pie the part of structural convenience I actually want, without making all user types accidentally interchangeable.

Copy, move, clone

Slice also needs to know whether a value is cheap and safe to copy.

Small primitive values copy freely:

a -> 1
b -> a
println(a) # still fine

But non-copy values move by default:

user -> load_user()?
taken -> user
println(user.name) # error: user was moved

If you want a real duplicate, you should ask for one:

a -> "hello"
b -> a.clone()

any is not a foundation

Pie has any, but I don’t want Pie to become a dynamically typed language wearing a static trench coat.

any is for:

  • quick scripting edges
  • foreign values
  • dynamic plugin-style APIs
  • places where the exact type isn’t known until runtime

Most Pie code should not need it. If any becomes the easiest way to write normal programs, then the type system has failed 😔.

What Slice is supposed to become

The long-term goal is that Slice can track enough information to make safe Pie genuinely safe while still feeling friendly:

  • ordinary local inference
  • explicit public function boundaries
  • ownership and borrowing in the type model
  • region-aware escape checks
  • generic constraints that don’t become unreadable
  • Result and Option as normal generic ADTs with language support for propagation
  • safe async/thread boundaries
  • raw pointer operations fenced inside unsafe

Current implementation and syntax design are not always at the same level. Some pieces already work in the compiler. Others are in the design document and need more implementation work before they are real enough, and some are written enough to just barely work.

Structs and methods

Structs are straightforward:

struct User:
    id: int
    name: string
end

user -> new User(id: 1, name: "Ada")
println(user.name)

Field assignment works on mutable owners:

mut user -> new User(id: 1, name: "Ada")
user.name <- "Grace"

Methods are functions with a receiver:

fn User.display_name(self: &User) -> string:
    if self.name == "":
        return "anonymous"
    end

    return self.name
end

fn User.rename(self: &mut User, name: string):
    self.name <- name
end

Internally, a method can be treated like a function with a receiver parameter and a mangled/resolved name, the code can say:

println(user.display_name())
user.rename("Grace")

instead of manually calling something like User_display_name(&user) everywhere.

Enums, ADTs, and matching

Pie enums are algebraic data types:

enum Message:
    case Quit
    case Move(int, int)
    case Write(string)
end

You can match them:

fn handle(msg: Message):
    match msg:
        case Message.Quit:
            println("bye")
        case Message.Move(x, y):
            println("move", x, y)
        case Message.Write(text):
            println(text)
    end
end

This is the same foundation used by Option and Result:

value: Option(int) -> Some(69)

match value:
    case Some(n):
        println(n)
    case None:
        println("missing")
end

Patterns let the compiler bind payload values safely in the matching arm.

Pie should warn when enum matches are not exhaustive. Some editor tooling already has a lightweight version of this idea. The compiler should eventually have the real version.

Result and Option

Pie does not use exceptions for normal recoverable errors, it just uses values:

fn parse_user_id(text: string) -> Result(int):
    if text == "":
        return Err(0)
    end

    return Ok(text as int)
end

Then a caller can handle the result explicitly:

match parse_user_id(text):
    case Ok(id):
        println("id", id)
    case Err(code):
        println("bad id", code)
end

or use propagation:

fn load_user(text: string) -> Result(User):
    id -> parse_user_id(text)?
    return Ok(new User(id: id, name: "Ada"))
end

? means “unwrap if okay, otherwise return early.”

I previously experimented with a prefix try operator (try parse_user_id(text)), but decided to deprecate it. Postfix ? is cleaner and scales nicely on larger code when chaining nested result calls.

id -> parse_user_id(text)?

means something close to:

match parse_user_id(text):
    case Ok(value):
        value
    case Err(err):
        return Err(err)
end

For Option, the same idea applies with Some and None.

Ownership

Pie is meant to be safe by default, the current implementation is still a partial version of that goal tho:

  • non-copy values have one owner
  • moving a value transfers ownership
  • using a moved value is an error
  • shared references can read
  • mutable references can write
  • shared and mutable borrows can’t overlap incorrectly
  • references can’t outlive the thing they point at
  • raw pointer operations require unsafe

A move looks like this:

user -> load_user()?
taken -> user
println(user.name) # error: user was moved

A borrow looks like this:

fn print_user(user: &User):
    println(user.name)
end

user -> load_user()?
print_user(&user)
println(user.name) # still valid

A mutable borrow looks like this:

fn rename(user: &mut User, name: string):
    user.name <- name
end

mut user -> load_user()?
rename(&mut user, "Grace")

The important rule is that &mut is exclusive. If something is mutably borrowed, then you should not also be reading or writing the owner through another path.

This fails:

mut name -> "Ada"
view: &string -> &name
edit: &mut string -> &mut name # error: shared borrow already exists

This also fails:

mut name -> "Ada"
edit: &mut string -> &mut name
println(name) # error: can't use owner while mutably borrowed

The current borrow checker already tracks a useful subset of this: moved non-copy values, shared borrow counts, mutable borrow state, some struct/list/map cases, region-local escapes, and references returning from functions. It’s not a finished “Rust-level” borrow checker :P

Why not write lifetime syntax everywhere?

Because I don’t want normal Pie code to look like someone with schizophrenia sat down while also on drugs and during a stroke wrote some code.

Pie still needs lifetimes as a concept, but most users should not have to write lifetime names in ordinary code.

This should be enough:

fn first(items: &list(string)) -> &string:
    return &items[0]
end

The compiler should infer the relationship, some things may need explicit annotations, but it shouldn’t be everywhere.

Regions

Regions are Pie’s answer to a very common allocation pattern:

Hey, I need a bunch of temporary objects, and I don’t wanna individually free them.

The syntax is:

region request:
    tokens -> tokenize(source)
    ast -> parse(tokens)?
    check(ast)?
end

At the end of the region, region-owned temporary allocations can be released together.

The safety rule is that region-local references can’t escape:

fn bad() -> &string:
    region temp:
        message -> "Ada"
        return &message # error
    end
end

Current implementation note: region blocks and escape diagnostics exist in a partial form. Full region allocation syntax like new in arena and region-aware APIs are not done.

Unsafe code

Pie has unsafe code, but it should be obvious.

fn main() -> int:
    mut n: int<32> -> 10

    unsafe:
        ptr: *int<32> -> &raw n
        *ptr <- 123
    end

    println(n)
    return 0
end

Raw pointers exist:

ptr: *byte

Raw pointer dereference is unsafe:

unsafe:
    value -> *ptr
end

Pointer arithmetic is unsafe:

unsafe:
    next -> ptr + 1
end

Calling an unsafe function is unsafe:

unsafe fn read_raw(ptr: *byte, len: int) -> list(byte):
    ...
end

unsafe:
    bytes -> read_raw(ptr, len)
end

Unsafe code is sometimes what a language needs, but it should be obvious.

Good Pie libraries should wrap unsafe internals in safe APIs:

pub fn read_file(path: string) -> Result(string):
    unsafe:
        fd -> os.open(path)?
        defer os.close(fd)
        return Ok(os.read_all(fd)?)
    end
end

Generics and monomorphization

Pie uses square brackets for generic parameters:

fn identity[T](value: T) -> T:
    return value
end

Multiple type parameters work like this:

fn pair[T, U](first: T, second: U) -> T:
    return first
end

Constraints use where:

fn max[T](a: T, b: T) -> T where T: Ord:
    if a > b:
        return a
    end

    return b
end

Implementation-wise, generic functions are monomorphized, so the compiler creates versions for concrete types used by calls.

So this:

x -> identity(670)
y -> identity("hello")

can become two internal functions: one for int, one for string.

Closures

Pie closures use fn expressions:

fn main() -> int:
    add: (int, int) => int -> fn(a: int, b: int) -> int:
        return a + b
    end

    result -> add(3, 4)
    return result
end

The type signature use the fat arrow => for closure types to decouple variable declarations and avoid arrow collision with the binding arrow -> (i heard ya and i changed it).

Collections

Pie has lists:

items -> [1, 2, 3]
items.push(4)
value -> items.pop()?

Maps:

scores -> {
    "Ada": 10,
    "Grace": 12,
}

scores.put("Linus", 8)
score -> scores.get("Ada")

Tuples:

pair -> (1, "one")
println(pair.0)
println(pair.1)

Ranges:

for i in 0..10:
    println(i)
end

for i in 0..=10:
    println(i)
end

And collection iteration:

items -> [10, 20, 30]
mut sum -> 0

for i, item in items:
    sum += item
end

Strings

Strings are UTF-8.

name -> "Ada"
message -> "hello, " ++ name

String interpolation currently exists with (...) syntax:

name -> "Ada"
age -> 71
msg -> "name=\(name) age=\(age)"

The syntax above was made because of limits of my language at that time, I am considering {...} now:

message -> "hello, {name}"

That is another syntax detail that should be settled. Just which one is better to use?

String methods include things like:

name.contains("x")
name.upper()
name.lower()
name.trim()
name.replace("old", "new")
name.repeat(3)

Strings are represented as pointer-plus-length in many paths. Null-terminated strings are convenient for C, but they are not a good representation for a language-level string.

Defer

defer schedules cleanup for the end of a function:

fn cleanup():
    println("cleanup done")
end

fn main() -> int:
    println("start")
    defer cleanup()
    println("end")
    return 0
end

Early returns:

fn process(path: string) -> Result(void):
    file -> fs.open(path)?
    defer file.close()

    if file.is_empty():
        return Err("empty file")
    end

    parse(file)?
    return Ok()
end

The deferred cleanup runs whether the function exits at the bottom or returns early.

Async and threads

The language includes async, await, spawn, channels, and eventually better concurrency tooling:

async fn fetch_user(id: int) -> Result(User):
    response -> await http.get("https://.../api/users/{id}")?
    return json.decode[User](await response.text()?)
end

The current implementation isn’t a full async runtime, tho it does have some thread runtime pieces and tests around thread spawning, mutexes, channels, and sleeping.

The safety rule for concurrency is simple:

  • shared mutable state across tasks must use synchronization
  • data races should not be possible in safe Pie
  • moving/capturing values into tasks should obey ownership rules

The compiler pipeline

At a high level, the pipeline is:

.pie source

source loader

lexer

parser

AST

semantic analysis

borrow/lifetime checks

IR lowering

backend code generation

assembler + linker + runtime

native executable

The current compiler is written in C11. The current native target is Linux x64, but at v1.0.0 it will have support for windows and other systems or wtv.

Pie does not use LLVM, MLIR, Cranelift, a parser generator, or a third-party compiler framework. The frontend, AST, semantic analysis, Slice type checking, borrow checking, IR, lowering, backend, and runtime are all custom project code (i dont really like using other people code).

That isn’t because LLVM is bad. LLVM is amazing. It’s because Pie is also a compiler project, and I want to understand and control the whole thing. I want the language’s weird decisions to be represented directly in the compiler instead of translating everything into someone else’s model stuff.

That might change later, either as a different branch or something else. A custom object writer, a different backend, or even an optional LLVM backend could exist someday, but the current identity of Pie is very much a custom language, custom compiler, custom type system and a custom runtime.

Lexer

The lexer turns source text into tokens:

fn main() -> int:

becomes:

FN IDENT(main) LPAREN RPAREN ARROW INT_TYPE COLON

It also handles comments, literals, strings, number formats, keywords, operators, and line/column information for diagnostics

Parser

The parser turns tokens into an AST.

Pie’s parser is hook-based, feature capsules can register parsing hooks for top-level declarations, statements, prefix expressions, and infix expressions.

That makes features more isolated:

operators.arithmetic     parses +, -, *, /, %, **
control.if               parses if/elif/else/end
collections.list         parses list literals and indexing
memory.unsafe            parses unsafe blocks and raw operations
declarations.structs     parses structs, construction, fields, methods

Semantic analysis and Slice checking

Semantic analysis checks meaning:

  • is this name defined?
  • is this type valid?
  • are function arguments correct?
  • is this assignment allowed?
  • does this return match the function return type?
  • is this operation valid for these operands?
  • is break inside a loop?
  • is an unsafe operation inside unsafe?

The sema pass also records type information needed by lowering, it’s where inferred local types are resolved, generic calls are checked, reference permissions are represented, and the compiler decides whether an operation is valid for the type of value it has and so on.

Pie starts rejecting programs that are syntactically valid but semantically wrong:

name -> "Ada"
name += 1 # nuh uh

or:

fn main() -> int:
    return "hello" # nope
end

Some parser/lexer diagnostics already have line/column information, and many sema/codegen errors are still plain strings.

Borrow checking

After sema, Pie runs a borrow checking pass.

This tracks state such as:

  • whether a non-copy value has been moved
  • how many shared borrows exist
  • whether a mutable borrow exists
  • whether a local reference escapes a function
  • whether a region-local borrow escapes a region

Basically:

name -> "Ada"
other -> name
println(name) # use after move

should be rejected.

And:

mut name -> "Ada"
view: &string -> &name
name <- "Grace" # can't assign while borrowed

will be rejected.

The borrow checker is intentionally separate from basic semantic analysis.

Lowering

Lowering turns AST into IR, it’s closer to what the backend needs. It has explicit statements, expression kinds, local IDs, lowered call forms, field assignments, method calls, runtime helper calls, and so on.

Examples:

  • expr? lowers into match/early-return behavior
  • if let Some(x) = maybe: lowers into a match-like form
  • for item in items: can lower into index iteration
  • method calls can lower into functions with receiver arguments
  • closures lower into environment structures plus callable code

Backend and runtime

The backend currently targets native Linux x64 through assembly output and linking.

Pie does not lower into LLVM IR or hand ownership of code generation to an external backend, it takes Pie IR and emits native-target assembly itself. That means every missing feature hurts more, because I don’t get a giant backend for free :(

Runtime provides basic operations the generated code relies on, such as printing, strings, lists, maps, maybe bool, and thread helpers.

The language includes multiple targets:

native-linux-x64
native-linux-arm64
native-macos-arm64
native-macos-x64
native-windows-x64
wasm32-web
wasm32-wasi
freestanding-x64

But right now, Linux x64 is the real target (i’m a linux user what can i say). Cross-platform support is a goal for v1.0.0

Feature capsules

One of the most important implementations is the feature-capsule architecture!

A feature capsule is a slice of the compiler for one language feature. For example:

src/features/control/if/
    parse.c
    sema.c
    lower.c
    codegen.c

or:

src/features/operators/arithmetic/
    parse.c
    sema.c
    lower.c
    codegen.c

The idea is that a feature can own its own lifecycle:

  1. parse source syntax into AST
  2. check semantic validity
  3. lower into IR
  4. emit backend code or participate in codegen

A generated registry just wires these hooks into the compiler.

This gives Pie a few benefits:

  • modularityyyyyyyy
  • feature work is easier to isolate
  • tests can be organized around language features
  • tools can understand the feature list
  • experimental features can be kept separate from core machinery :D

It isn’t perfect, some features are too central to isolate well. for loops, for example, interact with collections and lowering. Blocks are parsed and checked as features but codegen may be inlined by lowering.

Still, this architecture has helped keep the compiler from becoming just a bunch of 5k+ loc files, which is an ancient evil I wanna avoid after failing 3 times before.

Modules and packages

The module system has three visibility levels:

fn helper() -> int:
    return 1
end

pub fn visible_inside_package() -> int:
    return helper()
end

export fn visible_to_dependents() -> int:
    return visible_inside_package()
end

The current implementation:

  • require paths parse
  • required local modules can be loaded recursively
  • circular requires are detected
  • functions, structs, and enums from required modules can be merged
  • pub visibility is used for imports from modules
  • from "path" import name exists with optional aliases
  • simple package layout works for executable packages
  • pie add / pie remove can edit dependencies in pie.toml

The full package system isn’t done yet, a real dependency resolver, lockfile, package cache, registry, target profiles, library package support, and versioning rules still require implementation 😔.

The intended project layout is:

pie.toml
src/
  main.pie
  lib.pie
  routes.pie
tests/
  user_test.pie
assets/

A small manifest might look like:

[package]
name = "hello"
version = "0.1.0"
edition = "2026"

[deps]
http = "1"
json = "1"

[targets]
default = "native"

Tooling

A language without tools isn’t finished.

pie new app hello
pie new lib parser_tools
pie add http
pie build
pie run
pie test
pie bench
pie fmt
pie doc
pie check
pie lsp
pie profile
pie debug

Not all of these are implemented though!

There is also a VS Code extension i wrote with syntax highlighting, snippets, and parser-light language server behavior.

The ideal version of Pie has:

  • one formatter
  • one package manager
  • one test runner
  • one docs generator
  • one LSP
  • one style

That does not mean users can’t build extra tools, just means the first experience should be pleasant, not annoying by downloading 5 different things.

Diagnostics matter alot

Bad diagnostic (this is actually what pie outputs rn):

error main:1: type mismatch

Better diagnostic:

E0020: type mismatch
  expected: int
     found: string
  at src/main.pie:12:18

help: convert explicitly with "as int" or change the binding type

Borrow errors especially:

can't mutably borrow "user" while it's shared

is okay, but it should also show where the shared borrow started and where the mutable borrow was attempted.

This is high priority and I plan on working on it before v0.3.0 releases.

What currently works

This section is dangerous because it can become stale immediately, so remember there might be more.

The compiler currently has working or partially working support for:

  • C11 compiler implementation
  • Linux x64 native output through assembly and linking
  • lexer and parser
  • AST construction
  • semantic analysis
  • custom Slice type checking foundation
  • IR lowering
  • generated feature hook registries
  • feature capsules for many primitives, operators, declarations, control flow, collections, memory features, modules, and stdlib print
  • local package workflows for executable packages
  • structs, field access, field assignment, and methods
  • enums with payloads
  • match statements and match expressions
  • lists, maps, tuples, ranges
  • strings and several string methods
  • functions and closures
  • generic functions and monomorphization
  • some generic constraints
  • Option, Result, Some, None, Ok, Err
  • postfix ?
  • if expressions and if-let style behavior
  • immutable-by-default bindings and mutable assignment
  • references and mutable references for important cases
  • partial borrow checking
  • region blocks and some escape diagnostics
  • unsafe blocks, unsafe functions, raw addresses, raw dereference, raw stores, raw pointer arithmetic
  • defer
  • basic thread/mutex/channel runtime experiments
  • package require loading and some import visibility
  • pie build, pie run, pie check, pie test, pie add, pie remove workflows

That list sounds large, but a language can have many working parts and still not be nowhere ready.

What isn’t done

A tad smaller list:

  • full standard library
  • stable language spec
  • complete borrow checker
  • complete region allocation model
  • complete module/package/dependency system
  • cross-platform native targets
  • WASM backend/tooling
  • complete async runtime
  • stable ABI
  • polished diagnostics
  • formatter
  • docs generator
  • package registry
  • real LSP
  • debugger integration
  • profiler
  • optimization passes
  • debug info
  • panic/runtime error
  • full Unicode text correctness
  • broad standard library tests
  • enough examples for real users

This is the honest state of the project, not tryna being negative. Pie has enough implemented to be real and somewhat usable, but not enough implemented to actually work on it.

Questions and answers

Is Pie trying to replace Rust?

Nope!

Rust is a mature language with a huge ecosystem, serious tooling, production users, and years of work behind it. Pie is an experimental language by one lady who has made several questionable decisions :P.

Pie borrows ideas from Rust, especially ownership, borrowing, Result, Option, and explicit unsafe boundaries (although i have not used Rust, or have gone too deep into how it works so I’m a bit just larping). But the goal isn’t to clone Rust, it’s goal is to explore a different ergonomic point.

Is Pie trying to replace Python, A, B, C and G?

Also no.

Pie wants some Python-like readability, but it isn’t dynamically typed Python with native code slapped onto it. Pie has static types, ownership, references, explicit mutation, native compilation, and a very different runtime model.

The inspiration is just readability

Why have end if indentation already shows the block?

Because indentation is for people, and end is for the language.

I want code to be readable when formatted nicely, but I don’t want invisible whitespace to decide where a block ends. end also makes nice compact examples possible:

if ready: start() end

That does not mean everyone should write one-line blocks everywhere, just means the syntax allows it!

Why not braces?

I don’t like how they look in the kind of code I want Pie to encourage, it would just look like a sore thumb.

That is subjective, syntax is subjective. Anyone who tells you syntax is purely objective is trying to sell you braces and semicolons.

Why use -> for binding?

It makes declarations visually distinct:

x: int -> 1

The left side says what is being introduced. The right side says where the value comes from, it also keeps the colon meaningful.

What is the syntax for type inference?

Simply drop the colon and write:

x -> 5

This keeps code clean, and beautifully retains the binding theme.

Why not use :=?

:= is fine. Other languages use it well.

Pie’s syntax came from a specific shape I wanted:

name: int -> 1

At this point that shape is part of the language’s identity. Could it still change before v1? Technically yes. Do I want it to? No. Will i change it? 95% not.

Are Result and Option hardcoded?

They should behave like normal generic ADTs as much as possible.

? needs to understand them specially, in the same way many languages give special treatment to certain traits, interfaces, or built-in protocols. But users should still be able to pattern match them, pass them around, understand them as values and so on.

Do Result and Option only hold strings?

Nope.

This was a question people asked because early examples often used string errors. The types are generic:

Option(int)
Result(User)
Result(User, ParseError)

The design often uses Result(T) as a convenient default, while implementation tests already use forms like Result(int, int) too.

Why did I drop try in favor of postfix ??

Because postfix ? handles everything elegantly and chains nicely without the parenthetical grouping clutter required by prefix try:

# Standard clean chaining
email -> db.get_user(id)?.get_profile()?.email?

Having both operators added redundant parsing logic to the compiler, so I deprecated try.

Is maybe serious?

Kind of. Also not really. Also yes.

maybe is a boolean value that randomly chooses true or false at runtime:

a: bool -> maybe

It isn’t important to Pie’s core design. It’s a small weird feature that made me happy, so it exists. Languages are allowed to have a few toys as long as the toys don’t break the house :P

Why write the compiler in C?

I use C a lot, I know it the most and it makes the early runtime/backend work very direct.

Is C the safest language to write a compiler in? No. Is it the fastest way for me personally to make progress on Pie right now? Yes.

Long term, it would be funny and probably healthy for Pie to compile more of itself. But self-hosting isn’t just here and done, I want to focus on optimizing the C compiler as much as possible before self-hosting it.

Why not use LLVM?

I wanted Pie to be its own compiler, not only its own syntax.

Using LLVM would have made some things easier. I would get mature optimization passes, object emission, target support, debug info paths, and years of backend engineering.

But Pie isn’t only a language experiment for me, it’s also a compiler experiment. I want the type system, borrow checker, IR, lowering, runtime model, and backend to be mine enough that I understand why the program works when it works and why it breaks when it breaks!

So Pie does not currently use LLVM, Cranelift, MLIR, or a third-party compiler framework. It has a hand-written C11 compiler, a custom IR, a custom backend, and a small custom runtime.

That isn’t automatically better. It’s more work. It means fewer targets, fewer optimizations, more bugs, and many problems that existing compiler frameworks already solved decades ago.

But also, when Pie has a feature, it’s because I had to model that feature in the compiler. I don’t get to hide all the hard parts behind a backend and pretend the language is done.

Why emit assembly?

Because it made the first native backend tangible.

Emitting assembly isn’t the only possible backend strategy. C output, custom object emission, or another backend could make sense later. The current assembly backend is a practical first backend though.

The language should not be designed around NASM, that’s why this post does not spend three thousand words on registers.

Is the borrow checker finished?

No.

It catches mistakes, tracks moves and borrow conflicts for important cases. It has region/reference escape diagnostics. But it’s not done.

The goal is to expand it until safe Pie is actually safe.

Are regions implemented?

Partially.

The syntax and some escape checks exist but that’s prety much it.

Can Pie call C?

The languae includes easy C interop.

The current compiler/runtime are already living in C and native ABI territory, but a polished user-facing FFI story is still for the future me.

Is Pie production-ready?

No. Nope. nuh uh.

Not even in a “haha maybe” way. It’s experimental, it’s for language-design feedback, compiler people, and curious people.

Then why publish it?

Because a private language can only grow so much, looking at the syntax and the code everything seems fine, but a person might know the answers to:

  • does this syntax read well?
  • where is it confusing?
  • what should be removed?
  • what should be implemented first?
  • what error messages are bad?
  • what is too complicated?

Pie needs that feedback more than it needs a 7th rewrite.

The design tension

Pie keeps running into the same triangle:

readability
safety
control

It’s easy to get two.

Readable and safe can become high-level and restrictive, safe and controlled can become verbose and intimidating, readable and controlled can become unsafe.

Pie is trying to sit somewhere in the middle:

region request:
    user -> load_user(id)?
    body -> render(user)
    return Ok(http.html(body))
end

That code should feel light.

But this code should feel dangerous:

unsafe:
    ptr: *byte -> alloc(byte, 4096)
    defer free(ptr)
    raw_read(fd, ptr, 4096)
end

The visual difference is part of the design. Safe code should look safe, and unsafe shouldn’t

What I hope for feedback on

The first public release isn’t about convincing everyone to use Pie, it’s more about finding out what Pie actually is when other people look at it.

  1. Does name: Type -> value still feel good after more than one example?
  2. Are : and end pleasant or annoying?
  3. Is type inference using name -> value clear?
  4. Are regions understandable from the syntax alone?
  5. Does the ownership model feel approachable without lifetime syntax everywhere?
  6. Are the generics readable?
  7. Do closures utilizing the decoupled => signature read cleanly?
  8. What feature looks unnecessary?
  9. What feature looks missing?
  10. What would make you actually try writing a program in Pie

Closing

Pie is still early, but it’s no longer just a dream syntax :D

There is a compiler. There is a runtime. There are tests. There are examples (200 as of typing this).

It’s not stable, not production-ready, and definitely not better than the languages you already use.

But it’s real enough!

Pie is my attempt to build a language that feels friendly without giving up native control, and safe without making every small program feel huge.

Maybe that’s a good idea, maybe a terrible idea. Either way, I would rather find out in public :P