Reading fastC: side-by-side syntax with plain C
A column-by-column tour. If you can read C, you can read fastC in twenty minutes — but the things that look the same do not always mean the same thing.
This post is the tour we wish someone had given us in week one. fastC is C-shaped on purpose — C-programmers can sight-read it, and the compiler emits readable C11 you can audit. But the surface familiarity hides two things: a small number of additions the type system enforces (capabilities, contracts, explicit casts), and a small number of subtractions where fastC removes a C wart on principle (implicit narrowing, ambient I/O, the preprocessor as a build system).
Read C in the left column. Read fastC in the right column. Where the columns diverge, that is where the wedge is.
Hello, plus
The shortest meaningful fastC program. hello.fc:
// fastC
fn add(a: i32, b: i32) -> i32 {
return (a + b);
}
fn main() -> i32 {
let x: i32 = 10;
let y: i32 = 20;
let result: i32 = add(x, y);
return result;
}
// plain C
#include <stdint.h>
int32_t add(int32_t a, int32_t b) {
return a + b;
}
int main(void) {
int32_t x = 10;
int32_t y = 20;
int32_t result = add(x, y);
return result;
}
The differences worth pointing at:
fninstead of a return type leading the declaration. There is no “is this a function or a variable” parse ambiguity. The grammar is unambiguous everywhere.let name: T = expr;— types are required, not optional. fastC does not have implicit declarations and does not have C’s “int by default” lurking.-> Tfor the return type. Reads left to right.- Parens around the
+in the return. The parser is not picky about that, butfastc fmtis — there is one canonical style and it parenthesizes. - No
#include. fastC does not have a preprocessor. There is no textual include, no#define, no#ifdef. Modules are real modules anduseis the only way to import.
The fastC program above compiles to a hello.c file you can read, then to a 53 KB stripped binary. In the C class for size; same order of magnitude as Zig.
The arithmetic that bites: integer overflow
// plain C
int sum(int n) {
int total = 0;
for (int i = 1; i <= n; i++) total += i;
return total;
}
// fastC
fn sum(n: i32) -> i32 {
let total: i32 = 0;
let i: i32 = 1;
while (i <= n) {
total = (total + i);
i = (i + 1);
}
return total;
}
The bodies look almost identical. The C program will silently wrap when n is large enough; you will get a wrong number and no warning. The fastC program will, by default, trap at runtime via fc_trap() when the signed overflow happens. There is no “the bytes look plausible but the math is wrong” mode. You get the right answer or you get a trap.
This is a cost we pay, deliberately. The fib(40) benchmark runs ~26 % slower than C with -O2 because of this check. We think the price is correct for the use case: code that an agent wrote, that a human reviewer is going to look at briefly, that ships to production.
Capabilities: the surface change that matters most
This is the one piece of syntax that has no C analogue.
// plain C
char* read_config(void) {
FILE* f = fopen("/etc/app.conf", "r");
// ... fread, return buffer
}
void parse_args(int argc, char** argv) {
char* cfg = read_config();
// any function, anywhere, can do this
}
// fastC
use caps::init;
fn read_config(c: ref(CapFsRead)) -> Str {
discard(c);
// ... a real fs::read takes the cap
return cstr("..."); // placeholder; real example takes a path
}
fn parse_args(args: Vec[Str]) -> i32 {
// This function cannot call read_config. It has no CapFsRead
// in its signature. The compiler will not let it reach for one.
return 0;
}
fn main() -> i32 {
let caps: Caps = init(); // root caps minted here, exactly once
let cfg: Str = read_config(addr(caps.fs_read));
return parse_args(...);
}
CapFsRead is a type. caps.fs_read is a value. ref(CapFsRead) is a borrow of that value. The capability is mintable only in main via caps::init(), and from there it flows downward through call arguments. No function “just has” a CapFsRead. A function that wants to read a file has to take one as an argument, which means the caller has to have one, which means somewhere up the chain the cap originated in main. This is what the language design literature calls an “object-capability” model, and the agent-language win is that the I/O surface of any function is visible in its signature.
The flow-analysis enforcement landed in stage 1.4. Pre-stage-1.4 the types existed but were unenforced; the syntax was final, the checker filled in behind it.
Contracts: @requires and @ensures
// plain C — by convention, in a comment
// pre: divisor != 0 && divisor > 0
// post: returns value / divisor
int32_t safe_div(int32_t value, int32_t divisor) {
return value / divisor;
}
// fastC
@requires(divisor != 0)
@requires(divisor > 0)
fn safe_div(value: i32, divisor: i32) -> i32 {
return (value / divisor);
}
@ensures(result >= 0)
fn abs(x: i32) -> i32 {
if (x < 0) {
return (0 - x);
}
return x;
}
The @requires clauses are checked at function entry; the first that fails wins. The @ensures clauses are checked at every return site, where the identifier result is bound to the value about to be returned. v1 lowers both to runtime asserts (if (!cond) fc_trap();). v2.1 (already in preview) pipes them through three-tier discharge: syntactic (always on, no Z3), Z3 with a 500 ms per-obligation budget, and the runtime fallback for anything tier-1 and tier-2 cannot prove.
The annotation set is small and fixed — @requires, @ensures, @mem, @caps, @panics, @purity, @complexity. An agent reading a fastC signature with the full annotation set sees the function’s operating manual without reading the body.
The build system: declarative, no scripts
// Cargo.toml (Rust analog)
[dependencies]
serde = "1.0"
// + a build.rs that runs at compile time
// and can do anything Rust code can do
# fastc.toml
[deps]
fastc-http = { url = "https://github.com/Skelf-Research/fastc-http", rev = "abc123...", sha256 = "..." }
The fastc.toml is declarative. There is no build.rs to read. There is no executable step at fetch or build. The rev and sha256 are checked on every build; drift fails loudly. The cosign keyless signature is verified. Vendoring is mandatory — the dependency tree lives in your repo, not in a global cache that you have to trust your last cargo update against.
The supply-chain attack story this prevents is concrete. The 2025 Rust typosquats faster_log (~5K downloads) and async_println (~3K) both worked via build.rs that exfiltrated Solana and Ethereum private keys at compile time. The 2026 typosquat of timeapi.io exfiltrated CI .env files via build.rs. fastC has no build.rs. The bug class is gone, not patched.
Cross-compilation: one flag
$ # C, via the toolchain you have
$ aarch64-linux-gnu-gcc hello.c -o hello-arm
$ # ... or 50 lines of CMake to do it portably
$ # Rust
$ rustup target add aarch64-unknown-linux-musl
$ # plus a linker config and maybe a sysroot
$ # fastC
$ fastc build --target=aarch64-linux-musl
fastC ships eight pre-wired zig cc presets: aarch64/x86_64 × linux-musl/linux-gnu, aarch64/x86_64-macos, wasm32-wasi, riscv64-linux-musl. One brew install zig and --target=wasm32-wasi gives you a .wasm for sandboxed runtimes. The escape hatch is --cc-override=<path> for proprietary toolchains. The output is portable C11 so any C cross-compiler in the world targets fastC binaries — we just default to the one that works without sysroot setup.
What is not in fastC
A list, because it is informative:
- No preprocessor. No
#include, no#define, no#ifdef. Conditional compilation lives infastc.tomland module-level attributes. - No implicit casts.
cast(i32, x)everywhere a width or sign changes. The Cint → longpromotions do not happen silently. - No
goto. Structured control flow,break,continue, earlyreturn. The few cases where C-programmers reach forgotoare clearer in fastC with early returns. - No void pointer.
raw(u8)is the moral equivalent for a byte buffer; struct-shaped opaque data is a struct. - No varargs. Variadic logging is handled by a fixed-arity
kv_int/kv_strfamily inmod log. Variadic printf is in interop only. - No async, yet. Stage 2.3. Until then, fastC is synchronous and the surface stays small.
- No central package registry.
fastc.devis a search frontend over GitHub. There is no service to compromise.
The path to fluency
If you read C, you can read fastC in twenty minutes. Writing it fluently takes a week, mostly to internalize: types on every binding, parens on every binary operator, capabilities flow through arguments, contracts live above signatures. After that the language is small enough that it stays in your head.
The compiler emits readable C11. When something is unclear, fastc compile foo.fc -o /tmp/foo.c and read what it generated. The output is stable: the same .fc input produces byte-identical C output across runs. That stability is what fastc fmt --check and the deterministic-build guarantee are built on.
For the long version of any of the above, the language guide has the grammar, type system, and idiom catalogue.
Comments? Issues? Disagreements? Open an issue at github.com/Skelf-Research/fastc/issues.