Skip to content

Fels Language Overview

Fels is an experimental systems programming language, implemented in Zig. A few principles shape its design:

  • No hidden allocations
  • Dynamic dispatch is explicit, through *Interface values
  • Static polymorphism comes from monomorphized generics
  • Allocation zones give you lexical ownership without a GC
  • Runtime checks trap out-of-bounds indexing, null pointers and overflow close to their source. They stay on in release builds too, and release_fast is the opt-out.

To build Fels and run its tests:

Terminal window
zig build
zig build test

To build with the LLVM backend, pass the path to your LLVM:

Terminal window
zig build -Dllvm-path=path/to/llvm

These compiler commands get you started:

fels check <file> Parse and type-check
fels ir <file> Print LLVM IR
fels object <file> Emit object file
fels build <file> Build an executable for the host OS, or for --target linux / windows
fels run <file> Build it and run it (-- <args> passes arguments to the program)
fels build <file> -g With debug info: a .pdb beside a Windows program, DWARF in a Linux one
fels build <file> --target uefi A UEFI application (.efi), no operating system
fels build <file> --target freestanding An ELF with no operating system, for a kernel
fels build <file> --target aarch64-freestanding The same for 64-bit ARM (also aarch64-uefi)
fels build <file> --link-arg <flag> Pass a flag to the linker (e.g. --script=kernel.ld)
#import std.io.{ println };
main :: fn() -> i32 {
println("hello from Fels");
return 0;
}

Here is a quick tour of the syntax in one file:

#import std.io.{ IoError, print, println };
// Constants use :: (compile-time binding)
MAX_SIZE :: 1024;
// Struct declaration
Greeting :: struct {
name: []const u8,
count: i32,
}
// Error type
AppError :: errors {
not_found,
overflow,
}
// Enum with payloads
Color :: enum {
red,
green,
blue,
custom: u32,
}
// Interface
Sink :: interface {
write :: fn(self: *Self, data: []const u8) -> usize ! IoError;
flush :: fn(self: *Self) -> void ! IoError;
}
// Interface implementation
Greeting as Sink {
write :: fn(self: *Self, data: []const u8) -> usize ! IoError {
return print(data);
}
flush :: fn(self: *Self) -> void ! IoError {}
}
// Function with generics
identity :: fn(value: $T) -> T {
return value;
}
// Fallible function
parse :: fn(input: []const u8) -> i32 ! AppError {
if input.len == 0 { fail .not_found; }
return 42;
}
main :: fn() -> i32 {
// Type-inferred locals
x := 42;
name := "Fels";
// Typed locals
count: i32 = 10;
data: [4]u8 = .{ 1u8, 2u8, 3u8, 4u8 };
// Pointers
ptr := &count;
*ptr = 20;
// Slices
slice := data[:];
first := slice[0];
sub := data[1..3];
// Control flow
if x > 0 {
println("positive");
} else {
println("non-positive");
}
// While loop
i := 0;
while i < 10 {
i += 1;
}
// For range
for j in 0..10 {
// j goes from 0 to 9
}
// For slice
for val in slice {
// iterate over elements
}
// Match
color := Color.red;
match color {
.red => { println("red"); },
.green => { println("green"); },
.blue => { println("blue"); },
.custom |value| => { println("custom {value}"); },
}
// Error handling
result := parse("hello") catch err {
return 1;
};
// Zones (arena allocation)
zone scratch: Arena {
buf := scratch.new_slice(u8, 64);
buf[0] = 42u8;
// arena freed at end of zone
}
return 0;
}
  • Systems language: there is no GC, and you manage memory by hand through zones
  • Everything explicit: no allocation is hidden from you, and dispatch is always explicit
  • Safety by default: every build except release_fast catches the faults listed above at runtime
  • Simple module system: each file is a module, and pub controls visibility
  • Growing incrementally: the semantics get completed before more syntax is added

From version 0.13 on, the syntax is settled: what the syntax reference shows keeps its form and its meaning. A new feature comes as a form no program uses today, an attribute (#name) or a builtin, not as a new meaning for something you can already write.

The checks go on getting stricter where the compiler accepted something it should not have, as 0.13 did for writing through a []const T, for numbers of two types that could lose a value, and for sub-byte integers. The CHANGELOG lists each such change with what to write instead. While the major version is 0, the standard library’s functions may still change in a minor version.