I like programming languages and developer tooling. It’s nice when people’s tools help them to develop their ideas more effectively.
I have thoughts about my ideal programming language tooling setup. Some of these thoughts would probably require person-decades of work to realize. Also, unfortunately, it’s likely that some aspects of what I want are in direct conflict with other aspects. Tradeoffs exist, after all.
No text files
Notable prior reading: Unison.
The code repository should be stored and checked in to source control in an abstract semi-compiled form.
To edit code, you would search through the repository using a CLI or GUI tool, then select the function or module you want to edit. This would open a text file in a regular editor.
To save your edits, you’d save the text file and use the tool to update the canonical stored definition from your text file.
The tool would ensure your code type-checks, then compile it down to some abstract intermediate form (like an AST) and save that.
The semi-compiled form would amenable to source control, either through things like a git filter or (more likely) just being human readable itself, but not meant for human editing.
Benefits of this approach:
- The tool would easily support automated refactors like renames or passing new function arguments down through a deep chain of calls.
- Because changes are precisely tracked, things like incremental computation and selective test execution (only re-running relevant tests after a change) are made easier.
- Content addressable code. Passing code itself through the network. Reification. (This is largely what Unison’s branding focuses on nowadays, it seems.)
Type system
- Strong, static typing.
- Local type inference for making editing files easier.
- Existential types/interfaces.
- Pervasive dependent types, or more generally some way to encode pre and post conditions into the type system. For example, when accessing the element of an -element array, the invariant that is known to the type system.
- Building on that: An escape hatch in the form of extremely easy way to check if an arbitrary property holds at runtime, and optionally giving the witness to that fact in the type system.
- Coroutines/generators, not just subroutines.
- Higher order functions. Functions as first or near-first class.
- Recursion should only be possible when provably bounded. This should be combined with information about how small the call stack might be, to eliminate the possibility of stack overflow at compile time.
- Ability to say exactly what ownership-related operations are allowed, like creating, moving, dropping, or taking references to values. Via this we can express things that are tough to express in current Rust, like async Drop, fallible Drop, and non-droppable types.
Effects
Notable prior reading: On the purported benefits of effect systems.
Everything that may cause a side effect should be marked as such. Maybe via an “effects system”.
But maybe instead of introducing a whole “effects system” with new and different rules, we can model effects by passing regular function arguments that take the parts of the “real world” that we need to access.
The program entry point would be passed an entire “real world”, and then you can pass down bits of the “real world” to functions that may need it.
It should be possible to look at a function signature and know: what side effects can it have? This would require us to answer: what are side effects? Should probably be at least:
- Filesystem read/write
- Network read/write
- Access randomness
- Panic or terminate the program
- Allocate memory dynamically
- Spawn threads
Effects should be interfaces when appropriate, so you can “mock” the real world by passing a mocked type that e.g. provides randomness with a constant return 4, or a network call type that always returns HTTP 200 OK. You can’t really mock terminating the program though.
With this system, if we see a function that takes nothing from the “real world”, we can be sure it’s only doing very basic things like just reading/writing from CPU registers, doing integer arithmetic, branching, etc.
This is why the auto-refactor tools are important. People would get annoyed if every time they needed to add an effect to a deeply-nested function, it then meant they had to manually update hundreds of function signatures and call sites to thread through the effect-giving value. That rote work should be done for them automatically. Computers should serve humans, not the other way around.
Care needs to be taken around higher-order functions/closures. Should we require functions that takes closures to explicitly pass the effects to the closures?
For example, if we have a function F that takes a function G of e.g. type int -> int, can G close over some effect-giving values? Then when we call G from the body of F, we’re actually accessing an effect, even though that didn’t show up in the type of G?
We should maintain the property that we know exactly what effects can be accessed at any give function call site.
Concurrency
Notable prior reading: Notes on structured concurrency, or: Go statement considered harmful.
There should be no unbounded “spawn thread” or “spawn process” API. As the post explains, this is akin to goto.
Put another way, you must always be required to join thread/process handles. This should be known to the compiler, in two ways:
- The compiler should know you have to join handles
- The compiler should know that since you have to join handles, other potential optimizations or transformations are available
Something like Loom where you can see/prove all possible parallel executions correct would be cool too.
Cross-network-boundary compiler
Notable prior reading: React Server Components, Temporal.
You should be able to write code on the frontend and backend, or between services, and have it be well typed and resilient to network failures. The compiler should see the full picture of what cross-service calls exist, require to you pass well-typed arguments, and generate auto-retry code when appropriate, and also force you to handle the realities of distributed systems, where any node or service can degrade at any time.
Built for evolution
Notable prior reading: What Functional Programmers Get Wrong About Systems.
The tooling should understand in what order different components and services get deployed, and at what versions these different components may be reading and writing typed protocol messages. It should be a compile error to, for instance, introduce a new enum variant or remove a required field from a record/struct, when it is not known that all possible readers of the type have been updated to handle this.
This would likely require tight integration with the source control system, to observe the evolution of the codebase over time, and the deploy/monitoring system, to understand what versions of code are running across your services.
Real-world resources known to the compiler
Notable prior reading: Dark, Wing, Distributed Systems Programming Has Stalled.
This is getting a bit more into areas I know less about, and also kind of far from what a usual programming language is.
With that said: I really don’t like how currently we have “regular code” in “proper” programming languages, but then when it’s time for Kubernetes resources or DNS rules or network access control lists or whatever, everyone breaks out the YAML and the hardcoded config strings and the going into the cloud console to fiddle around with buttons and UIs.
Everything should be fed into a big “compiler” which then understands what actual resources you need, both in the sense of on-node resources like threads, memory, and disk space, and distributed resources like IP allowlists, DNS rules, etc. The compiler should know what services are deployed where, how they talk to each other, what nodes need to exist, etc etc, and arrange for that to happen.
I’ve heard some of the BigCos have something close to this technology, but they don’t open-source it.
Other miscellaneous things
- Fully specified with operational semantics and mathematically proven sound.
- C code is gonna keep existing. We’ll need some way to interface with it. Something like
unsafefrom Rust perhaps. You may need to pass in the whole “real world” to FFI calls in the worst case, since most languages don’t have precise side effect tracking like ours will. - Excellent debugger. Can change running code like in Lisp. Can step into and through running code. Maybe even rewind it. Similar to the demos shown in Bret Victor’s talk.
- Memory safe. Rust or better. No data races. No deadlocks? Maybe no locking at all, only message passing?
Closing thoughts
I claim that the fact that we feel the need to use LLMs to generate code, code that we increasingly don’t even want or bother to review, shows that the programming languages and tooling we’ve built aren’t powerful enough.
Imagine if LLMs arrived before “Go To Statement Considered Harmful”, one of Dijkstra’s many important works and the original “considered harmful” paper, and we still wrote programs with gotos. Or if LLMs arrived even before that, when all we had was assembly.
We’d probably say exactly what we say today, which is: Look! We can generate thousands of lines of code really easily and quickly according to common patterns! This will increase productivity!
Except the maintainability of this mess would be a nightmare, because it’d be totally intractable for humans to gain a good mental model of it.
I believe that we’re essentially in that state right now, with our current set of programming languages and LLMs. It’s a bit better because current languages are indeed more higher level than gotos and assembly, but it could be way better, with better languages and tooling.
Programming is theory building. Our languages and tooling should let us humans focus on building the theory and do for us the rote, repetitive work to make the theory a reality.