—What is the best programming language?
This is a question I get asked a lot. I teach programming to fresh CS majors, and then compiler construction and some advanced programming language concepts to senior CS majors. So I get asked this question, a lot.
For the sake of the argument, let’s take it at face value for a minute. What’s your pick? Python? JavaScript? Rust? Haskell? Or… Church forbids… LISP!? Or… is this somehow a wrong question?
My response to this question is very pragmatic—I’m a pragmatist for most things that matter, and a romantic for the rest, you’ve been warned.
All other things considered equal—I always say—, assuming no cost in learning a new language, no personal preference, no company policy, etc., the absolute best programming language is the one where you can most easily express the solution to the problem you care about. Neat, right?
—But hey—I hear you scream—that’s not a real answer! That’s just a “depends”.
And yes, it is. All complicated questions have “depends” for an answer. The real answer is understanding what it depends on. And in this case, I think the clearest metric to optimize—again, all else considered equal—is how far will a specific language take me towards solving the problem I need to solve.
If I had a programming language with a set of keywords and builtin operations that basically would let me type a problem and it would get automatically solved—and that thing kind of exists for some domains, think Prolog or SQL—then, that would be the best programming language of all, right!
But of course, this is kind of a moot argument because, in the end, almost all mainstream programming languages are equally good or bad at almost the same things. You can talk to a database in any programming language. You can generate and serve HTML in any programming language. You can write standard business logic in any programming language. You can shoot your foot with regular expressions in any programming language.
Granted, there are some cases where a pure functional language is better in some niche problems but, for the most part, it’s not the language per se, but the set of available libraries and frameworks that make it or break it for any given problem.
And that’s precisely the point of this article! Thing is, what most people think about when they refer to a “programming language”—you know, those things with funny names like Python, Java, Rust, Go, Erlang…—is an extremely limited concept. I want to show you a far broader notion of what a programming language is; a notion that I think will make some of you look at the very act of programming in a completely new, and enlightening way.
But let’s start at the beginning.
What is a Programming Language?
We often talk about programming primarily from the perspective of problem-solving. We take a messy, real-world problem—or so the story goes—and try to distill it into something a computer can understand.
And that’s where the programming language enters the picture. It’s the very language you write the solution to the problem, which is later converted into something the computer can actually run. For the most part, you can even imagine the code in that programming language is actually what’s running on the CPU—at least for the 3rd generation, imperative programming languages that are mainstream today.
From this point of view, a programming language can be defined as a formal language—with a strict syntax and formalized semantics—that can be used to instruct a computer to solve problems in a given domain.
This perspective is absolutely right. It shows programming involves a great deal of abstraction via decomposition, which is to say, we must take a problem and break it into parts so small that are trivial to explain to a computer. But I think this perspective also misses a crucial, often-overlooked part of problem-solving: abstraction via composition. Let me elaborate.
For any, even mildly complicated problem, chances are you will not solve it just with say, pure, Python, for the sake of the argument. No, you will first write a couple of utility functions, perhaps one or two classes here and there, and only then, you’ll write a rather small main function that simply instantiates a type and calls a couple of methods and voilá, problem solved!
What you just did there, my friend, is invent a new language!
Ok, not an entirely new language, but a new something still. You added a couple of methods and classes with their own, formalized semantics, that can be used to instruct a computer to solve problems in a given domain.
Think about it again from the perspective of problem-solving. If only you had that magical language where just typing the problem would give you the solution. Well, that’s what you built. You created a set of building blocks (methods, classes, etc.) that, when you use them to express your problem, you get it automatically solved.
In a sense, all programming is a form of language design. Just as we must distill a problem down into programmable parts, we must also upgrade our programming language with new building blocks that can directly solve those subproblems.
Now let’s see a few ways in which we do implicit language extension when we program.
The Layers of Language Extension
Metaprogramming is perhaps the most evident form of language extension. These are programming language features that directly extend or enhance the language's own syntax. The quintessential expression of metaprogramming is, of course, LISP macros—and similarly inspired macro systems like Rust’s. But other, less flexible features like Python decorators or code blocks in Ruby can also add a level of linguistic manipulation that sometimes seems like you’re programming in a whole different language.
However, the point here is not about metaprogramming. Or not primarily about metaprogramming—that’s just one tiny bit of implementation detail. You don’t need any fancy metaprogramming features to extend a programming language. Most of the code we write is this kind of language-extending code. Functions and types, as we just discussed, are the most basic building blocks in most programming language and also the more pervasive and impactful form of language extension.
Every time you define a new function or class, you're effectively adding a new word to your language vocabulary. You're extending the language you're working with, making it more expressive for the specific problem at hand, and at the same time, more specialized.
I see this happening across four distinct language layers. Each layer adds a new vocabulary, more expressive—in the sense that you can say more with fewer tokens—but also more specialized—in the sense that you can talk about a more reduced set of concepts. Each of these layers encapsulates knowledge about an increasingly more complex part of a problem domain, and each layer encodes a stronger opinion about how a given problem domain is tackled.
Let’s take it from the ground up.
Layer Zero: The Base Language
This is the most basic layer where we always start a new project, the raw programming language itself. Think of Python, C, Java, or JavaScript. At this level, the language is at its most general, most unopinionated. It gives us the fundamental tools: how to store numbers, how to make decisions, and how to repeat actions. The knowledge encapsulated here is about the very basics of computation, how a computer thinks and operates.
Level 1: Libraries
Once our problem exceeds what the builtin functionality in our language can solve, we turn to libraries. If you’re writing, say, numerical algorithms in Python, you probably won’t code everything from scratch. No, you’ll use Numpy.
You can see libraries as a more specialized linguistic layer that offer ready-made functional building blocks for a specific domain. The knowledge encoded in libraries is almost entirely functional: domain-specific algorithms to solve particular types of problems efficiently.
Level 2: Frameworks
This is where things get more opinionated. Frameworks, like a web framework (e.g., Django, React) or a game engine, don't just give you tools; they give you a way of thinking about and solving problems. They encode a particular paradigm or mindset. They tell you the right way to structure your application, the right patterns to follow, the right way to combine these disparate algorithms.
The knowledge encapsulated within a framework is procedural: it's about the best practices and compositional rules for a given domain. If libraries are specialized functions, frameworks are like an architectural blueprint for building a specific type of system.
Level 3: The Application
Finally, we have the top layer: applications designed to solve a concrete problem for a specific user. When you build, e.g., a CRM or a simple CRUD app, that interface is also a language with which the user instructs the computer to solve a concrete problem—be it create a report or change a user password.
Applications encode knowledge about a business domain—business in the sense it’s a problem that some client wants to solve. And every application, in a subtle way, implicitly contains its own framework (its own set of compositional rules) and its own custom library (its own building blocks).
Oftentimes this code is intermingled. But in well-designed applications you can often extract a fully-featured framework and a couple of libraries that can be reused for building other similar applications in adjacent domains.
Why this matters
If I’m right then every programmer is, in essence, a language designer. This is a great power, and with it comes great responsibility!
Deciding which abstractions (classes, methods, functions) to create is an exercise in designing a more expressive, more capable language for your specific problem. When you think about it, abstraction itself is fundamentally about finding the right representation. This involves a two-sided approach we’ve already discussed:
Decomposition (Top-Down): Breaking down a big problem into smaller, more manageable, computationally solvable pieces.
Composition (Bottom-Up): Actively building and adding new capabilities to your language. This is where you encapsulate functionality into reusable blocks.
The sweet spot where these two directions converge is where you find the perfect level of abstraction, where your problem understanding and your solution language align beautifully.
But, of course, we rarely explicitly program this way. We don’t build the perfect framework or library upfront. More often, we build an application, realize certain patterns are emerging, and then extract a framework or library from it. Only after a lot of experience in a given domain we begin to see the patterns at the start of a new project and can design upfront. And even then, we often make mistakes and design abstractions that aren’t exactly right, or maybe even unnecessary.
But that’s ok. My argument here is not that you should define a sweet spot you want to achieve, and then build your language up to get there. No, the process of problem-solving in inherently messy and there’s nothing that can substitute experimentation.
However, I do think this dual-view of programming is helpful from at least two perspectives. First, as a programmer, it helps to sometimes step back and consider what is the language you need to solve your current problem, and work with that as at least a flexible guide. Again, this doesn’t you’ll get it right from the start. But I find it helps me to rethink and refactor a convoluted solution when everything gets stuck.
And second, as an educator, this perspective gave me a powerful shift in how I teach computer programming. Instead of just teaching students to solve problems with a language, I now try teach them to be explicit about their intentions when designing abstractions, and to understand the new linguistic layers they are always creating. This means teaching them how to break the problem down to fit the existing language constructs and, simultaneously, teaching them how to build the language up with new constructs to better fit the problem.
As a side note, this perspective also helps explain why almost all programming languages start as a cheap C rip-off—a slow, low-level, mostly imperative general-purpose language—and gradually evolve towards a LISP wannabe—a highly extensible systems with powerful metaprogramming capabilities, but still not LISP.
But this is a topic for another article.
I’m gradually learning to code, and I truly believe this issue is a must-read for anyone who’s even just dipped their toes into programming. Even though I’m still at a very basic level, it offers a thoughtful reflection on what it really means to code — on awareness, intention, and the act of building, not just writing, code. Thank you, Alejandro, as always, for your clarity and inspiration.