A Journey Through Programming Languages
I fell in love with programming as a child, and since then I could not stop.
I think about programming languages the way a biologist thinks about an ecosystem — it’s hard to say which species you prefer. Each new language represents a new philosophy, a new way of viewing the world. To become truly productive, you must adapt to this view, let it reshape how you think about problems.
The Early Days
I started with BASIC, moved on to assembly on 8-bit machines, but it was C that became my first professional language. We worked with Informix 4GL on SCO Linux, and compilation times could literally take 15-20 minutes. This made for painfully long iteration cycles.
That frustration led me to write my first programming language — a Forth interpreter. I had seen this strange language on my Sinclair QL and was fascinated by its simplicity and power. We integrated it into our C codebase, and suddenly we could move windows, change text, add menu items in seconds instead of hours.
That was the first time I experienced what I consider truly interactive coding.
Languages That Shaped My Thinking
Over the years, I’ve explored many languages. Each one taught me something different:
| Language | What It Taught Me |
|---|---|
| BASIC | First love — where it all began |
| Java | Fast, stable — version 1.0 was mind-blowing |
| Perl | “Can it really be done this easily?” |
| Haskell | “Can code really be this beautiful?” |
| Ruby | Oh, Rails… the joy of convention over configuration |
| C# | A language that knows how to evolve |
| Pascal | The reliable old runner |
| Lisp | Interactive development and mind-expanding macros |
| Forth | Strange, but you can do anything |
| OCaml/F# | So elegant, truly underestimated |
| Prolog | A gift — learning to think this differently |
| Pharo Smalltalk | Everything is an object — the best development environment there is |
| Erlang/Elixir | Non-blocking and async in every part |
| JavaScript | Hard to find a better survivor in the language jungle |
Many languages not mentioned here, but not forgotten. It has been quite a ride.
What True Interactivity Means
Most languages force you to choose: either you get fast compilation with an interpreter (but slow execution), or you get fast execution with a compiler (but slow iteration). Nostos uses a two-phase compiler to give you both.
Phase 1: Rapid Development When you’re in the REPL or developing, Nostos compiles to bytecode that runs on a register-based VM. Compilation is near-instant. You can redefine functions, inspect state, and see results immediately — just like Lisp or Forth.
Phase 2: Production Performance When code paths get hot, or when you’re ready to deploy, the JIT compiler (powered by Cranelift) kicks in and compiles to native machine code. No interpreter overhead, no startup lag.
This is what I mean by truly interactive — you never have to wait for compilation during development, but you also never sacrifice runtime performance.
But there’s more to it than just fast compilation. The real killer of productivity is the restart-and-restore cycle. You make a change, restart your program, then spend time clicking through menus or re-entering data just to get back to the state where you can test your fix. This is perhaps the most frustrating bottleneck in programming.
Nostos is built to eliminate this. You can redefine functions while your program is running, test changes immediately against live state, and keep iterating without ever losing context. No restart. No restore. Just flow.
No Purity, Just Pragmatism
Nostos is not a “pure” language. It doesn’t insist that everything is a function, everything is an object, or everything is a clause. Those philosophies are beautiful in their own right — I’ve learned from all of them — but purity often comes at the cost of practicality.
Nostos is designed to be pragmatic. It borrows the best ideas from many paradigms without being dogmatic about any of them. The goal is simple: cut development time while still guaranteeing that best practices are followed.
Take global state, for example. Sometimes you need it — a database connection pool, a configuration object, a shared cache. Pure languages make you jump through hoops, threading state through every function or hiding it behind patterns like dependency injection or factory methods. But let’s be honest: it’s still a static variable at the bottom.
Nostos has MVars — mutable variables that are explicit about what they are. They’re visible in the type system, and the compiler ensures they’re accessed in a thread-safe way. No manual locking, no race conditions. When you need shared mutable state, you use an MVar. No ceremony, no pretense.
This matters not just for human developers, but also for LLMs. More on this in a later post — there’s a lot to say about how language design affects AI-assisted coding.
Shortness and Readability
There’s a common belief that short code is clever code, and clever code is unreadable. But that’s only true when shortness comes from tricks and obscure syntax. When shortness comes from expressing intent directly, the result is both shorter and clearer.
Here’s quicksort in Nostos:
quicksort([]) = []
quicksort([pivot | rest]) =
quicksort(rest.filter(x => x < pivot)) ++ [pivot] ++ quicksort(rest.filter(x => x >= pivot))
Three lines. If you know what quicksort does — pick a pivot, put smaller elements on the left, larger on the right, recurse — this code reads like that description. The empty list returns empty. Otherwise, filter the rest into smaller and larger, concatenate with pivot in the middle.
Pattern Matching Makes Intent Visible
Pattern matching is one of the reasons Nostos code stays readable even when it’s short. Instead of writing conditional chains that check structure, you describe the structure you expect:
# Handle different list shapes directly
describe([]) = "empty"
describe([x]) = "just one: " ++ show(x)
describe([x, y]) = "a pair: " ++ show(x) ++ " and " ++ show(y)
describe([h | t]) = "starts with " ++ show(h) ++ ", then " ++ show(t.length()) ++ " more"
describe([10, 20 | _]) = "first element is ten and the second twenty"
Each clause shows you the shape it handles. No if-else chains, no length checks, no index access. The structure is right there in the function signature.
Recursive Definitions Read Like Math
When you learned about factorial in math class, you probably saw something like:
factorial(0) = 1 factorial(n) = n × factorial(n - 1)
In Nostos, you write essentially the same thing:
factorial(0) = 1
factorial(n) = n * factorial(n - 1)
The code is the definition. No ceremony, no boilerplate. Just the idea, directly expressed.
This matters because code is read far more often than it’s written. When you come back to a function six months later, clear expression of intent saves real time. Short, readable code isn’t a luxury — it’s a maintenance strategy.
Types as Documentation
Types aren’t just for catching errors — they’re documentation that the compiler enforces. When you model your domain with types, the code becomes a description of the business rules.
Let’s say you’re building a banking system. An account isn’t just “active” or “inactive” — there are nuances:
type AccountStatus =
| Active
| Frozen(String, Date) # reason, until
| Closed(Date, Decimal) # closedAt, finalBalance
| PendingVerification(List[String]) # documentsNeeded
Each status carries exactly the data it needs. A frozen account has a reason and an end date. A closed account has a timestamp and final balance. An active account needs nothing extra. The type is the specification.
Now transactions. They’re not all the same either:
type Transaction =
| Deposit(Decimal, String) # amount, source
| Withdrawal(Decimal, Option[String]) # amount, atm
| Transfer(Decimal, AccountId, String) # amount, toAccount, memo
| Fee(Decimal, String) # amount, description
With these types, processing a transaction becomes straightforward:
process(account: Account, tx: Transaction) -> Result[Account, String] = {
# First check account status
match account.status {
Frozen(reason, _) -> return Err("Account frozen: " ++ reason),
Closed(_, _) -> return Err("Cannot transact on closed account"),
_ -> ()
}
# Then handle the transaction
match tx {
Deposit(amount, _) ->
Ok(account.addBalance(amount)),
Withdrawal(amount, _) ->
if account.balance >= amount
then Ok(account.subtractBalance(amount))
else Err("Insufficient funds"),
Transfer(amount, _, _) -> {
match account.status {
PendingVerification(_) -> Err("Transfers disabled until verification complete"),
_ -> if account.balance >= amount
then Ok(account.subtractBalance(amount))
else Err("Insufficient funds")
}
},
Fee(amount, _) ->
Ok(account.subtractBalance(amount))
}
}
Look at what we get for free:
- Frozen accounts reject all transactions with their specific reason
- Closed accounts can’t do anything
- Pending verification allows deposits but blocks transfers
- Active accounts handle each transaction type appropriately
The compiler ensures we handle every combination. If we add a new account status or transaction type, the compiler will point to every place that needs updating. No forgotten edge cases, no runtime surprises.
And notice how readable it is. You don’t need comments explaining the business logic — the pattern matches are the business logic. A product manager could read this code and verify it matches the requirements.
Fast Enough
Nostos is young, and it’s not hard to find cases where it’s slow. But when the JIT kicks in, the numbers look different:
| Fibonacci(40) | Time |
|---|---|
| Nostos | 0.651s |
| Python | 13.976s |
| Ruby | 11.307s |
| Java | 0.562s |
That’s 21x faster than Python, 17x faster than Ruby, and only slightly slower than Java.
But raw computation speed is only part of the picture. Real-world performance depends on how well a language handles concurrency, I/O, and resource utilization.
Nostos is built for this from the ground up:
- Work-stealing scheduler — all CPU cores stay busy, tasks automatically balance across them
- Lightweight processes — spawn a million of them without breaking a sweat, each costs only ~2KB
- Async everywhere — every I/O operation is non-blocking, no special syntax required
The future of software is async. Applications waiting on networks, databases, and each other. Nostos is designed for that world.
We’ve also tested list operations against Haskell (which excels at this) and held our own. For the adventurous, there’s even a benchmark comparing Phoenix against RWeb with 10,000 active WebSocket connections — RWeb handled roughly twice the message throughput. Phoenix is state of the art and so nice. I’m not claiming we’re “better” — I guess it’s not hard to find counter-examples — but it shows we’re on the right track.
Still, the speed that matters most isn’t benchmark speed. It’s development speed. How fast can you go from idea to working code? How quickly can you iterate? That’s what Nostos is really optimized for.
Breaking the Factory Line
There’s a famous scene in Charlie Chaplin’s Modern Times where he plays a factory worker on an assembly line, tightening bolts all day. The repetition drives him mad. He becomes so conditioned to the motion that he can’t stop — he chases people down the street trying to tighten buttons on their clothes.
Charlie Chaplin in Modern Times (1936)
This is what modern web development has become.
Somewhere along the way, the industry decided that “real” applications must separate frontend from backend. You need a JavaScript framework over here, a REST API over there, TypeScript definitions that mirror your database schema, JSON serialization layers, state management libraries, and an army of specialists who each understand only their piece of the puzzle.
We’ve turned programming into an assembly line. And just like Chaplin’s character, many of us got into this profession because we loved building things — not because we wanted to spend our days converting data formats and wiring up boilerplate.
This separation kills the joy of programming.
When I started coding, one person could build an entire application. You understood the whole system. You could trace a user’s click all the way down to the database and back. That holistic understanding made programming fun.
Nostos is my attempt to get back to that. Imagine connecting to a running web server and exploring its state, tweaking a function, testing it live — all without restarting. In test mode, of course, but still: that’s the kind of interactive power that makes development enjoyable again.
One Language, Everywhere
In Nostos, you write your HTML templates in the same language as your business logic. No context switching. No “now I’m in JavaScript land” versus “now I’m in Python land.” The same type system, the same functions, the same mental model — from the database to the browser.
fn render_user_card(user: User) -> Html {
html! {
<div class="card">
<h2>{user.name}</h2>
<p>{user.email}</p>
{if user.is_admin {
<span class="badge">Admin</span>
}}
</div>
}
}
This isn’t a template language — it’s just Nostos. The same type checker that validates your database queries also validates your HTML. If user.name doesn’t exist, you get a compile error, not a runtime crash in production.
Reactive Variables: State Without the Ceremony
Modern frontend frameworks have made state management into a complex discipline. Redux, Vuex, signals, stores, reducers, actions — the vocabulary alone requires a tutorial.
Nostos takes a different approach with reactive records. You define your state as a reactive type, render a view based on that state, and handle actions separately. The view stays clean — it just describes what to show. State changes happen in response to actions.
use stdlib.rweb.*
reactive Counter = { count: Int }
sessionSetup(writerId) = {
state = Counter(0)
renderPage = () => RHtml(div([
p("Count: " ++ show(state.count)),
button(text: "+", dataAction: "increment"),
button(text: "-", dataAction: "decrement")
]))
onAction = (action, params) => match action {
"increment" -> { state.count = state.count + 1 }
"decrement" -> { state.count = state.count - 1 }
_ -> ()
}
(renderPage, onAction)
}
main() = startRWeb(8080, "Counter", sessionSetup)
No selectors, no dispatch, no action creators. The view is a pure function of state. Actions are handled explicitly in one place. When state changes, the UI updates automatically — the framework diffs the DOM and applies only the necessary changes.
Here’s the key insight: the onAction handler runs on the backend. That means you have direct access to your database, file system, and any other server resources — right there in the same function that handles user interactions. No API layer to design, no endpoints to secure, no data serialization to worry about.
This also makes security simpler. Your business logic stays on the server where it belongs. The frontend only receives what it needs to render — no sensitive logic exposed, no attack surface in the browser.
This separation between view and action keeps your code maintainable. The view doesn’t mutate state directly; it just sends actions. You can read the onAction handler and understand every way your state can change.
If you still prefer the traditional approach, Nostos also has a fast, pure backend server that can serve a TypeScript frontend.
Typed Postgres: No Strange ORM
Most languages treat the database as a foreign system. You either write raw SQL strings and hope for the best, or you wrestle with an ORM that generates bizarre queries and forces you to think in terms of “entities” and “relationships” instead of just… tables and rows.
Nostos takes a different path: no ORM, just plain SQL with safe types.
You define a type that matches your query result, and Nostos maps the rows directly:
# Define the shape of your data
type User = { name: String, email: String }
main() = {
conn = Pg.connect("host=localhost dbname=mydb user=postgres")
# Map directly to typed records — no ORM magic, no hidden queries
users: List[User] = query[User](conn, "SELECT name, email FROM users WHERE age > $1", (18))
users.map(u => println(u.name ++ ": " ++ u.email))
}
That’s it. You write SQL — real SQL, the SQL you already know. The type annotation List[User] tells Nostos what shape to expect, and the compiler ensures the mapping is safe. If your query returns columns that don’t match the type, you’ll know at compile time.
No “active record” patterns. No lazy loading surprises. No N+1 query problems hidden behind innocent-looking property access. Just queries you write, results you understand, and types that keep you honest.
This is what “full-stack” should mean — not “I know both JavaScript and Node.js,” but “my entire application, from database to browser, is one coherent, type-safe system.”
Reclaiming the Joy
The point of all this isn’t just productivity — it’s about enjoying the craft again.
When you can see the whole picture, when you can make a change and understand its impact across the system, when you don’t have to juggle five different languages and a dozen libraries just to ship a feature — that’s when programming feels right.
At least, that’s the goal. Nostos is still young, and there’s a lot of work ahead. But every step brings it closer to the kind of tool I wish I’d had all along.
Easy FFI
Nostos is written in Rust, and that’s not just an implementation detail — it’s a feature. Any Rust library can be wrapped and called from Nostos with minimal boilerplate.
The FFI system is designed to be straightforward:
- Write your Rust code as a dynamic library
- Use the
nostos-extensioncrate to register your functions - Create a thin Nostos wrapper with type annotations
That’s it. No complex binding generators, no header files, no manual memory management on the Nostos side.
Here’s what calling native code looks like from Nostos:
# Import the extension
use nalgebra
# Use it like any other Nostos code
v1 = Vec.new([1.0, 2.0, 3.0])
v2 = Vec.new([4.0, 5.0, 6.0])
dot_product = v1.dot(v2)
The extension handles all the marshalling between Nostos values and Rust types. You get the ergonomics of a high-level language with the performance of native code.
This opens up the entire Rust ecosystem. Need machine learning? Wrap a Rust ML library. Need low-level system access? Write a small Rust extension. Need to squeeze every cycle out of a hot loop? Drop down to Rust for that part.
For a real-world example, check out the ModernBERT implementation built on top of the Candle ML library. It shows how to rebuild a pretty complex Python model in pure Nostos.
Stability
Let’s be honest: Nostos is not stable yet.
The type system with two compilation phases — bytecode VM for development, JIT for production — has needed careful work. Getting types to behave consistently across both phases is tricky, and there are still rough edges. You’ll find bugs. Some things that should work won’t.
This will improve. Every issue fixed makes the foundation stronger. That said, we’re getting close to 2000 tests, and every one passes — so we’re getting there.
But right now, Nostos is for the adventurous — people who want to explore, experiment, and help shape a language while it’s still forming.
If that sounds like you, consider joining the Argonauts. We’re a small crew, but we’re building something we believe in. And there’s always room for one more.
Why Nostos?
Nostos is my attempt to pick from the best of all these experiences:
- Async by default — because modern software is concurrent
- Fast enough — JIT compilation when it matters
- Readable — code should communicate intent
- Types — catch errors at compile time, not in production
- Interactive REPL — the development experience I fell in love with decades ago
- Two-phase compiler — flexibility without sacrificing safety
And yes, it’s written in Rust — which opens the door to a growing ecosystem of high-quality, battle-tested libraries.
After all these years and all these languages, Nostos is my homecoming.