A dense field of wooden formwork boxes, ladders and scaffolding rising in stepped blocks at the base of a rocky canyon wall, with small figures of workers on top
Series · Concrete — Systems programming with explicit guarantees

What Concrete Makes Worse

Concrete's constraints have real costs. Linear cleanup is verbose, hidden-capture closures are gone, and the ecosystem is still early. Here is what the language actually makes harder.

8 min read concrete · Programming Languages · linear-types · tradeoffs · language-design

On the image Pouring the dam as separate blocks, each with its own forms and cooling pipes, was slow and costly, and it was the price of concrete that would not crack as it cooled. Concrete’s verbose cleanup and missing conveniences are that kind of cost, paid on purpose. Timber formwork for the columns of Hoover Dam, looking upstream from the Nevada side, July 1933. Photo: unknown U.S. Bureau of Reclamation photographer, public domain, via Wikimedia Commons (National Archives 293931).

Series note: this is the tradeoffs entry in the Concrete series. For the foundation, start with Why Concrete Exists. For the most practical demo, read When the Compiler Is the Oracle.

The previous articles in this series argued that Concrete’s design constraints are worth it. Explicit capabilities make code auditable. Linear types prevent resource leaks at compile time. No hidden behavior means the compiler can report what your program actually does. I believe all of that. But I have been writing Concrete code for long enough to know where the constraints bite, and I have not been honest enough about that in public.

This article is about what Concrete makes worse. Not in theory, not as an abstract “it’s stricter.” Specific code that is uglier, longer, or more painful to write in Concrete than in Rust or Zig. If you are considering whether these tradeoffs are worth it for your domain, you deserve to see the cost up front.

#Linear cleanup is verbose and repetitive

In Rust, RAII handles resource cleanup. You open a file, use it, and when the scope ends the compiler inserts a Drop call. You never write the cleanup. Three resources, zero cleanup lines:

fn process(path: &str) -> Result<Report, Error> {
    let config = File::open("config.toml")?;
    let input = File::open(path)?;
    let mut output = File::create("report.txt")?;

    let settings = parse_config(config);
    let data = read_all(input);
    let report = analyze(&settings, &data);
    write!(output, "{}", report)?;
    Ok(report)
}

In Concrete, every resource needs explicit cleanup. Owned values must be consumed exactly once. If you forget, the program does not compile. That is the point, but here is what it looks like:

fn process(path: &String) with(File, Alloc) -> Result<Report, Error> {
    let config: Handle = match open(&"config.toml") {
        Result::Ok { value } => value,
        Result::Err { error } => { return Result::Err { error: error }; },
    };
    defer destroy(config);

    let input: Handle = match open(path) {
        Result::Ok { value } => value,
        Result::Err { error } => { return Result::Err { error: error }; },
    };
    defer destroy(input);

    let mut output: Handle = match create(&"report.txt") {
        Result::Ok { value } => value,
        Result::Err { error } => { return Result::Err { error: error }; },
    };
    defer destroy(output);

    let settings: Settings = parse_config(&config);
    defer destroy(settings);

    let data: Data = read_all(&input);
    defer destroy(data);

    let report: Report = analyze(&settings, &data);
    match write(&mut output, &report) {
        Result::Ok { value } => {},
        Result::Err { error } => { return Result::Err { error: error }; },
    }

    return Result::Ok { value: report };
}

Five defer destroy lines, plus a match block for every call that can fail, since Concrete has no ? operator. The function’s logic is the same, but most of the lines are cleanup and error-handling ceremony. Real Concrete code looks like this when you work with multiple resources. The ratio gets worse as functions get more complex.

It is tempting to say “well, at least you can see every cleanup site.” That is true. It is the reason the alloc report works, the reason auditors can trace resource lifetimes without reading the implementation of every type, the reason the oracle experiment could identify unnecessary allocations mechanically. But when you are writing the code, you feel the weight.

In Rust, you trust that Drop runs at scope exit and you move on. In Concrete, you think about destruction order, you type defer destroy for every owning binding, and you occasionally stare at a function wondering if there is a way to factor out the ceremony. There usually is not.

The worst case is error paths. If a function opens resource A, then tries to open resource B and fails, the explicit return Result::Err { ... } in the error arm runs the deferred cleanup for A. That part works. But if you need conditional cleanup, different paths owning different subsets of resources, the linearity checker forces you to handle every case explicitly. Rust’s Drop handles this invisibly. Concrete makes you write it out.

I think the tradeoff is correct for the domains Concrete targets. But I no longer describe it as “more annoying to write” as if it were a minor inconvenience. It is a substantial ergonomic cost that you pay on every function that manages resources.

#No hidden-capture closures hurts composition

In Rust, filtering a list is one line:

let active: Vec<_> = users.iter().filter(|u| u.is_active()).collect();

In Concrete, there are no closures in the Rust or JavaScript sense. No lambdas with invisible captures. You write a named function and pass it:

fn is_active(user: &User) -> bool {
    return user.active;
}

// inside a function declared with(Alloc)
let active: Vec<User> = filter::<User>(&users, is_active);

filter here is a small helper you write yourself. The standard Vec has map, fold, and for_each, but no filter.

This is fine for is_active. It is a meaningful predicate that deserves a name. But what about filtering by a threshold that changes?

In Rust:

let expensive: Vec<_> = items.iter().filter(|i| i.price > threshold).collect();

The closure captures threshold from the enclosing scope. One line, obvious what it does.

In Concrete, you cannot capture implicitly. The function you pass to filter can only use its arguments. If it needs the threshold, you have to say so: pass it as another argument, write a specialized helper, or pass an explicit function pointer with an explicit context value. Recent callable-value work makes that last option much more usable than it was when this article was first written. It still does not feel like a Rust closure, and that is the point. The context is visible. The callback’s capabilities are visible. The compiler can see the shape of the thing you are doing.

The rationale is real. Closures are hidden captures. A closure that captures a mutable reference is implicit aliasing. A closure that captures an owned value is an implicit move. A closure that captures by clone is an implicit allocation. In Concrete, all data flow is visible: function arguments go in, return values come out. Nothing is smuggled through a captured environment.

But expressiveness has a floor. Below that floor, code stops being clear and starts being bureaucratic. Simple data transformations, map/filter/reduce chains, callback patterns, event handlers, all of these are natural with closures and heavier when callback state has to be explicit. Concrete is still below the ergonomic floor for this class of problems.

The answer Concrete has settled on is not to sneak closures back in. It is bound callbacks: an explicit function pointer plus explicit context, with capabilities carried on the callback type and scoped borrows prevented from escaping. That is less pleasant while you are writing the code, but much easier to audit later. It preserves the thing Concrete cares about most: no hidden data flow.

#The missing ecosystem

If you try Concrete today, you will hit walls that have nothing to do with the language’s design.

There is no package manager. A project can list local path dependencies in Concrete.toml and concrete build picks them up, but there is no registry, no version resolution, and no lockfile. The formatter exists now, but it is still young compared with rustfmt or zig fmt. There is no LSP, so your editor gives you little: no mature autocomplete, no inline semantic errors, no go-to-definition. The standard library has more than 30 modules, which sounds like a lot until you need something it does not cover and realize you are writing it from scratch or calling C through FFI.

Rust has crates.io, cargo, rustfmt, rust-analyzer, and a library for nearly anything. Zig has a package manager and a growing ecosystem. Concrete has a compiler, a test runner, a formatter, and early audit/proof tooling.

This is a maturity problem, not a design one. The compiler works. The language is real. But the surrounding infrastructure that makes a language livable for daily work is early. If you pick Concrete for a project today, you are signing up to build some of that infrastructure yourself, or to wait.

I am not going to pretend this does not matter. Tooling is not secondary to language design. An immature formatter still leaves style and editor integration feeling rough. A language without an LSP means slower feedback loops. A language without a package manager means dependency management is manual labor. These are not luxuries. They are the difference between a language you can advocate for and one you use alone.

The plan is to build the rest of it. The roadmap puts packages before the editor and LSP work, and both after the public release bar. But plans are not tools, and I would rather be honest about what exists today than let someone discover the gaps after committing.

#The cost and the payoff come from the same source

Every pain point in this article traces back to the same design decisions that make Concrete’s strengths possible.

Linear cleanup is verbose because every resource lifetime is explicit. That explicitness is why the alloc report works, why the compiler can tell you exactly where allocation happens and through which call chain.

No hidden-capture closures is painful because it eliminates a natural composition pattern. That elimination is why all data flow is visible, why callback capabilities are trackable through the call graph, why the proof surface is not contaminated by invisible captures.

The missing ecosystem is the cost of building a new language instead of extending an existing one. That independence is why the grammar is LL(1), why capabilities are built in from the start, why the compiler can be an oracle instead of a gatekeeper.

These are not separate tradeoffs; they are the same one viewed from different angles. You cannot have the reports without the verbosity. You cannot have the trackable call graph without making callback state explicit. You cannot have a language designed for machine reasoning without starting from scratch.

For firmware, security boundaries, cryptographic policy, safety-critical components, I still think the tradeoff is right. The previous articles in this series explain why. This one explains what it costs. Both are true at the same time.

Updated October 2026 to match the current compiler: the process example now propagates errors with explicit matches (postfix ? was removed) and counts five defer destroy lines, the filter snippet uses current syntax, and the tooling section reflects path dependencies and the roadmap order for packages and the LSP.

Written with an LLM in the loop, like everything here. The ideas and the mistakes are mine. More on how I write.