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 withend - bindings use
name: Type -> value - functions use
fn name(args) -> ReturnType: - references use
&Tand 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
ResultandOptionas 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
breakinside 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 behaviorif let Some(x) = maybe:lowers into a match-like formfor 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:
- parse source syntax into AST
- check semantic validity
- lower into IR
- 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:
requirepaths parse- required local modules can be loaded recursively
- circular requires are detected
- functions, structs, and enums from required modules can be merged
pubvisibility is used for imports from modulesfrom "path" import nameexists with optional aliases- simple package layout works for executable packages
pie add/pie removecan edit dependencies inpie.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
requireloading and some import visibility pie build,pie run,pie check,pie test,pie add,pie removeworkflows
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.
- Does
name: Type -> valuestill feel good after more than one example? - Are
:andendpleasant or annoying? - Is type inference using
name -> valueclear? - Are regions understandable from the syntax alone?
- Does the ownership model feel approachable without lifetime syntax everywhere?
- Are the generics readable?
- Do closures utilizing the decoupled
=>signature read cleanly? - What feature looks unnecessary?
- What feature looks missing?
- 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