Evolving Languages for Collaborative Coding

Shriram Krishnamurthi recently asked on Twitter: “Most programming languages were designed in an era where mostly one programmer coded in isolation. If you were designing a language for modern collaborative programming (code reviews, pull requests, etc.), what would you change?”
That’s not just a provocative question — it’s the lens I’ve used to think about programming languages for year. This post is a little meandering answer to his question because a twitter thread would be too unwieldy.
In short:
I think a more collaborative programming language is one that makes code more locally reviewable.
I’ve had “security” in my title for most of my career, but I don’t think of myself as a security person. I know real security people — they’re brilliant, methodical, and maybe a bit terrifying. That’s not me.
My approach to security has always been through the lens of modularity — aggressive modularity. Some call it mutual suspicion. I think it’s deeper than that, and SK’s question gets right to the heart of it.
This post is an attempt to convince you that to make programming languages more amenable to collaborative development, the thing you should be optimizing for is removing all those things that result in action at a distance — whether they are through shared memory, shared computation or unshared understanding of parts of the language.
I’d like to get my thoughts out rather than right — expect non-thought-through parts. In the spirit of debate, here are my working assumptions:
1. Most large systems are built through small, reviewed changes to large, potentially buggy codebases.
2. Most people making or reviewing those changes don’t fully understand the whole system but have expertise in the part of the software they are building features for or fixing security bugs in.
3. Languages, tools, and systems should help increase the chance of correct implementation — and minimize the blast radius when things go wrong.
Background
I didn’t start with security. I started by noticing that all my CS classes assumed I controlled all the code. But even modest real-world codebases, even way back then, import libraries, and those libraries import more libraries. That means when you make a tiny change to a function and someone else reviews it, neither of you really understands what that change might ripple into. In some languages which are multi-threaded, a variable may unexpectedly change midway through what looks to the programmer and the reviewer like straight line code. In some languages which allow global operator overloading or monkey patching — where the meaning of core objects can be modified, loading a module may result in dramatic changes to the semantics of other code but not be obvious to a reviewer or collaborator eyeballing code.
And yet, we pretend it’s reviewable.
Worse: we’ve built languages and tooling that reinforce that illusion. I’m going to try to give examples why I think the illusion exists
The Core Mental Model
A good language for collaboration lets developers reading a snippet of code understand it’s full effect with only a small additional amount of code context.
That’s it.
If a one-line tweak to a file requires understanding ten other files — or silently breaks some distant invariant — that language has failed your team.
“Spooky action at a distance” isn’t just a physics joke. Einstein worried about effects that occur far from their cause. I worry about that too. Specifically, what happens when code can touch things it shouldn’t. And it wrecks collaboration.
You may say that this isn’t just a feature of the language but of team culture too. Fr’instance, there are teams who adopt coding conventions and that’s part of what makes code readable for them. I would argue that unenforced convention doesn’t actually enhance collaboration because you can’t be sure that another developer, in your team or in a partner team, is not following that convention. If it is enforced — say by a linter, then it necessarily subsets the language. That subset is actually the language you’re coding in.
I’ve tried in the past to create a metric to characterize languages by this criteria with only mixed success. But the intuition I think works. Given a diff, how much more of the code do I have to look at to understand if the change is “good”.
My friend Mike Samuel and I had a podcast discussion about this topic some years ago too.
Let me walk through a few features I think make languages not “spooky”. This won’t be comprehensive — just a curated sampling.
Lessons from the Field
I’m going to blaze through a bunch of features in different languages. You may not be familiar with some of the languages — do not worry. In a large part, that is because some of them are esoteric or are exploring an esoteric idea. What I like about them is they’re highlighting a particular attempt to change some aspect of the language to make them locally “reason”-able.
Rust: Make Effects Visible
Background:
Rust introduces a novel ownership model and compile-time enforcement of borrowing rules. These aren’t just for safety — they make the program’s structure more legible to humans.
Why It’s Understandable:
Ownership means data has a clear home, and mutation rights are visible in the function signature. This makes reviewing a change easier because coming side effects are signaled and who is responsible for a given resource is not merely clear — it’s enforced by the language.
fn append_value(vec: &mut Vec<i32>, value: i32) {
vec.push(value);
}Takeaway:
Languages that expose mutability and ownership at the API boundary are easier to review, debug, and trust. That’s not just safe — it’s reviewable.
Misty: Lexical Scope Means It
Background: Doug Crockford’s Misty language eliminates dynamic scope, implicit globals, and reflective metaprogramming.
Why It’s Understandable:
In Misty, functions can only access what’s lexically in scope or explicitly passed. This makes their behavior predictable and their contracts honest.
Takeaway:
Lexical scope with no hidden dependencies helps reviewers reason about a function’s behavior by reading just that function.
Misty strips away dynamic scoping and ambient magic. No this, no eval, no globals. Functions only operate on what they’re handed.
That’s not a limitation. It’s a feature: what you see is what it can do. That kind of explicitness is worth stealing.
DrRacket: A module says what language features it uses
Background:
DrRacket supports language levels, which provide subsets of the language to align with user experience and pedagogy.
Why It’s Understandable:
By restricting available features at each level, developers (or students) can focus only on the constructs they’ve learned. It removes surprises from the review surface.
Takeaway:
Gradual language subsets can help teams scale collaboration and review to varying experience levels.
DrRacket introduces language levels — constrained subsets of Racket that align with what the user understands.
This idea generalizes beautifully. Codebases have contributors with wildly different context. Tools (and languages) that acknowledge that — and limit power accordingly — make collaboration safer.
uFork: Capabilities and Messages
Background:
uFork is a minimal actor model platform with strict capability-based access control and no shared state.
Why It’s Understandable: Actors can only message other actors they have references to. This enforces locality and makes it clear what influence an actor can exert. More importantly, because actors only respond to messages rather than through shared memory, you can understand an actor in isolation.
Takeaway: When authority flows only through explicit references and explicit messages, collaboration and review become safer and more modular.
uFork builds a capability-secure, message-passing runtime from the ground up. No shared state. No ambient authority. You can only affect what you hold a reference to.
That matches what most developers think their code does. uFork actually enforces it.
The Mismatch Between Assumptions and Features
Here’s the structure I’ll use:
My guess about how people think code works.
Why the language breaks that assumption.
What we could do instead.
Assumption 1: Memory is Compartmentalized
Why it fails: C lets you write over memory you don’t own.
if let Some(tally) = tally.get_mut(vote as usize) {
*tally += 1;
} else {
invalid_votes += 1;
}
#define NUM_CANDIDATES 4
int tally[NUM_CANDIDATES] = {0};
int invalidVotes = 0;Diff that looks semantic preserving reversal of an if-statement but gets it wrong:
- if (vote < 0 || vote >= NUM_CANDIDATES) {
+ if (vote >= 0 && vote <= NUM_CANDIDATES) {Off-by-one but that’s ok — these kinds of mistakes occur. What is not apparent is that now you’re corrupting memory which can have consequences far away from this piece of code.
This kind of bug is hard to spot in a review because the diff looks benign — just a small change to a comparison operator. Unless the reviewer is intimately familiar with NUM_CANDIDATES and the layout of memory, they might not realize this change allows out-of-bounds access.
How to fix: No unchecked access. No casting values into pointers. A reliable memory safe type system. Look — bugs like this will still occur and review should catch it. But if it’s missed, the consequence should not be that now random memory addresses get overwritten.
More reviewable alternative: In Rust, for example, the compiler enforces bounds checking and ownership.
For a more interesting variation of this bug that can be used to subtly throw an election, I recommend reading Surreptitious Software on getting subtle bugs past code review (heh — shameless plug!)
Assumption 2: Functions Only Touch What They’re Passed
Why it fails: Python lets functions inspect the stack and modify parts of it.
This is hard to review because the function doing the snooping might live in a different file or module, and its abuse isn’t visible in the calling code. From the outside, helper_function() looks harmless.
How to fix: Remove stack inspection. Enforce lexical scoping.
More reviewable alternative: Languages like Misty or SES JavaScript prohibit access to variables outside the lexical scope, making it clear what can and cannot be touched.
def helper_function():
caller = inspect.currentframe().f_back
secret = caller.f_locals.get('secret')
print(f"Stole the secret: {secret}")
def process_vote():
secret = "SUPER_SECRET_KEY"Diff:
+ helper_function()While this example is contrived, it shows how even a benign-looking change — like adding a call to a utility function — could leak secrets if that function does something unexpected, like stack inspection. Reviewers looking just at the diff would not think to check the internals of every helper function, especially in large codebases.
Assumption 3: Built-ins Behave
Why it fails: JavaScript lets you monkey-patch nearly anything.
This makes review fragile because the patch may occur in a different part of the program, perhaps deep in a dependency. sort() might no longer sort, and you’d have no idea unless you audit every prototype mutation.
How to fix: Lock down prototypes. Disallow mutation by default.
More reviewable alternative: With SES, built-ins like Array.prototype.sort are frozen, making their behavior predictable.
Object.freeze(Array.prototype);Assumption 4: Dangerous Powers Aren’t Ambient
Why it fails: In most languages, any function can arbitrarily call system calls or external functions that have much more access than the function that called them. For example, a library function you import and call in Java could kill the JVM that your program is running in. Any library function you import can read a file, delete your files, or open a network socket — often with no indication in the diff. Any third party library can act as you.
This is difficult to review because a seemingly harmless function could have a large impact. Unless reviewers have full context or grep the entire repo, such ambient powers are invisible. The way we've traditionally gone about defending against this is the "reputation" of the library, it's authors or the company behind it hoping that we can use that as a proxy for "reliable about doing only what it said it would do".
How to fix: Remove ambient authority. Capabilities must be passed explicitly.
More reviewable alternative: For example, only explicitly passed shutdown objects should be able to terminate the process.
interface Terminator {
void terminate();
}// Function now depends on a Terminator capability
void shutdown(Terminator term) {
term.terminate();
}
`Assumption 5: Concurrency is Explicit
Why it fails: Shared mutable state invites invisible race conditions.
Reviewers often can’t tell whether a given variable is accessed by multiple threads. Race conditions don’t show up in diffs — they show up in prod, and only sometimes.
How to fix: Prefer message-passing. Avoid shared state entirely.
More reviewable alternative: Actor models like in uFork or Erlang make concurrency explicit and local.
actor.send(new Message(item)); // this is the only way data is sharedWhat next (or heh…how now Brown cow)?
You can evolve existing languages by changing the properties of existing if they have them:
Freeze global state (SES, import maps, frozen modules).
Use linting and subsets to remove ambiently powerful APIs from existing languages. This can be done with warnings at first then errors then a change in the language spec.
Find capability subsets of the language you’re fixing so functions have to explicit take powerful functions as an argument. In some languages, this can be painful. In others (ahem you know you are), it might not even be possible. But usually you can find ways to construct APIs such that they don’t naturally ambiently get to grab authority then rely on linting and review to catch the cases where you cannot remove the misfeature from the language.
Evolve existing languages towards actor like models. This is hard from a performance perspective but if you can show how a combination of threads and objects can let you fake a “Actors lite”, you can then convince languages to change to turn embrace what starts out as a shim.
Explicitly import new behaviors in lexically scoped blocks. I think this might be the hardest to do with existing languages rather than when you start with a blank slate. It would be interesting to see if for a given language that supports say macros, if you could emulate some language constructs as a macro desugaring, you could then make the import of the macro explicit. Of course, in practice you wouldn’t implement it as a macro necessarily but it would make it visible to the viewer of a given scope that a particular language feature was being used.
Why I Care
This has been my lens for a long time. When I worked on Caja, amazing people like Mark Miller, Doug Crockford, Carl Hewitt, Norm Hardy, Marc Stiegler…Shriram himself and many others helped refine what started as a gut feeling into a strategy to restrict ambient authority and make JavaScript safer for reuse. Their perspective was largely security but I found many of the same ideas make collaborative coding easier and code more reviewable.
Most security work assumes evil hackers. I assume tired devs making careless mistakes because they have partial context.
Let’s change languages for them.
Thanks for the prompt, SK.
No “Spukhaft” in Programming! was originally published in Recursive Rhymes on Reason on Medium, where people are continuing the conversation by highlighting and responding to this story.

