7 Languages in 7 Weeks for 2025

2024-11-25

It’s been over 14 years since the original 7 Languages in 7 Weeks was first published, giving a hands on tour of Ruby, Clojure, Haskell, Io, Scala, Erlang and Prolog. Ruby achieved critical mass, to some degree so did Scala, with the others being popular within their specific niches. This post shows 7 languages worth exploring in 2025.

A line-drawing of the number 7, in old Macintosh colours

The 7 languages we’re going to look at are, notably, ones that aren’t overlapping with 7 Obscure Languages in 7 Weeks. These are going to be a bit more mainstream. I’ll go through each language explaining why you should have it in your toolbox, followed by a small demo of what I feel makes the language unique. The first 3 languages are common, the latter 4 much less so.

Table of Contents

  1. Python
  2. TypeScript
  3. Go
  4. Gleam
  5. Zig
  6. Racket
  7. Odin

1. Python

Really, Python is about as mainstream as it gets - being ranked #1 in GitHub’s Octoverse report for 2024 and #3 in Stack Overflow’s survey for 2024. So why include this at all?

The reason is twofold - interoperability with the hottest technology of the last decade, and Python notebooks (with associated statistics packages). The latter, in my opinion, is Python’s USP - competing against R and Julia, but with significantly more ubiquity and flexibility than the former two.

To give an example of the latter - there are plenty of tutorials on machine learning models and integrating LLMs if you search around - lets leverage DuckDB and matplotlib to quickly generate a graph using notebooks in VSCode.

import duckdb
import matplotlib.pyplot as plt
import seaborn as sns

%matplotlib inline
%config inlineBackend.figure_format = 'retina'

# inline type hints!
GRAPHRC: dict[str, tuple | str] = {"figure.figsize": (11.7, 8.27), "figure.facecolor": "#f9ffff", "axes.facecolor": "#f9ffff"}
GRAPHSTYLE: str = "dark"
GRAPHFONT: str = "sans-serif"

sns.set_theme(style=GRAPHSTYLE, rc=GRAPHRC, font=GRAPHFONT)

db = duckdb.read_csv("title.ratings.tsv.gz", sep="\t")
query = """
SELECT
    tconst,
    averageRating
FROM
    ratings
WHERE
    numVotes > 50
""".strip()
df = db.query("ratings", query).pl()

ax = plt.axes()
ax.xaxis.label.set_color("#33321a")
ax.yaxis.label.set_color("#33321a")
ax.tick_params(which="both", colors="#33321a")
ax.title.set_color("#33321a")
ax.margins(0)
pt = sns.histplot(df, x="averageRating", bins=50, kde=True, color="#33321a", ax=ax)
plt.tight_layout()
plt.title("IMDb Ratings")
plt.xlabel("Rating")
plt.ylabel("Count")
plt.show()
A graph showing a histogram of IMDB ratings

A handful of code and we’ve got a histogram plotted out with a kernel density estimate, with our filtering done via SQL and reading straight from the gzipped TSV from IMDb Non-Commercial Datasets. We can go even further with Simon Willison’s wonderful Datasette for exploratory data analysis and presentation. Wow your colleagues and bosses with quick and dirty notebooks. Spend a week getting familiar with these integrations and keep it in your back pocket the next time you reach for Google Sheets or Excel.

2. TypeScript.

TypeScript gives us typed JavaScript. Why should you care? Perhaps best summed up in this meme from Annie Sexton:

A dithered two-colour image of Velma, from Scooby Doo, crawling around on the floor without her glasses saying 'My types! I can't see without my types!'

Typed languages are, fundamentally, safer languages to write in - being able to declare expected types and enforce their usage lets you have increased confidence that the software you write is correct.

What I find unique about TypeScript is its approach to gradual typing. If you don’t have a greenfield project, slowly introducing types lets you reduce risk piecemeal. Here’s an example:

const compact = (arr) => {
  if (orr.length > 10) {
    return arr.trim(0, 10);
  }
  return arr;
};

We’ve got a clear error above - orr is not arr and therefore would crash at runtime:

> compact([1,2,3])
Uncaught ReferenceError: orr is not defined
    at compact (REPL6:2:3)

It’s also ambiguous what arr is - multiple object instances have a length property, and trim() is not a valid Array method. So, we can gradually type this function, first in our editor:

// @ts-check
const compact = (arr) => {
  if (orr.length > 10) {
    // Cannot find name 'orr'.
    return arr.trim(0, 10);
  }
  return arr;
};

Then declare our param type via JSDoc comments:

// @ts-check

/** @param {any[]} arr */
const compact = (arr) => {
  if (arr.length > 10) {
    return arr.trim(0, 10); // Property 'trim' does not exist on type 'any[]'.
  }
  return arr;
};

Then finally convert it over with real type declarations:

const compact = (arr: string[]) => {
  if (arr.length > 10) {
    return arr.slice(0, 10);
  }
  return arr;
};

This approach to gradual typing, I feel, is pretty special. Other languages that have introduced typing, either via annotations like Python or separate systems like Ruby, do not pull it off as elegantly as this. Besides, TypeScript is one of the very few “universal” languages to write a complete application in. Consider spending a week writing a “full stack” application in TypeScript using Next.js.

3. Go

Go remains probably my personal favourite language to write, and it is worth your time too. So why Go? Its most compelling points are a “batteries-included” standard library, small surface area to learn (much like my favourite small language Lua) and easy binary shipping, including generating static binaries.

However, the “killer app” for Go is that coupled with a great type system, it is phenomenally easy to write applications that are easy to maintain. The Go team’s commitment to iterative improvement to the language without breaking backwards compatibility is extremely commendable.

Rather than providing a direct demo, I’m going to take a sample of code from my redis-rest-api project:

func newServer(ctx context.Context, cfg serverCfg, rc *redis.Client, um UserMap, auth authFunc) *http.Server {
	const maxBytes int64 = 1048576 // 1 MiB

	mux := http.NewServeMux()
	mux.HandleFunc("/", rootHandler(ctx, rc, um, auth))
	mux.HandleFunc("/pipeline", pipelineHandler(ctx, rc, um, auth))
	mux.HandleFunc("/multi-exec", txHandler(ctx, rc, um, auth))
	wrappedMux := http.MaxBytesHandler(mux, maxBytes)

	server := &http.Server{
		Addr:              cfg.ListenAddr,
		Handler:           wrappedMux,
		ReadTimeout:       cfg.ReadTimeout,
		ReadHeaderTimeout: cfg.ReadHeaderTimeout,
		IdleTimeout:       cfg.IdleTimeout,
		TLSConfig:         cfg.TLSConfig,
	}

	return server
}

Again, a handful of lines, some dependency injection, and baby you’ve got a stew server going with routing, limits and authentication. This also demonstrates another pattern I enjoy about Go - imperative programming making things easy to test. Oh right, testing! Unlike many other languages, Go comes with a test harness that is great without external libraries. An example from the above repo:

func TestAllowedCommands(t *testing.T) {
	testCases := []struct {
		expectedCommands map[string]int
		desc             string
		role             Role
	}{
		{
			desc:             "rw",
			role:             "rw",
			expectedCommands: AllowedRWCommands,
		},
		{
			desc:             "ro",
			role:             "ro",
			expectedCommands: AllowedROCommands,
		},
		{
			desc:             "missing_role",
			role:             "gg",
			expectedCommands: nil,
		},
	}
	for _, tC := range testCases {
		t.Run(tC.desc, func(t *testing.T) {
			if !reflect.DeepEqual(tC.expectedCommands, tC.role.AllowedCommands()) {
				t.Errorf("expected: %+v, got: %+v", tC.expectedCommands, tC.role.AllowedCommands())
			}
		})
	}
}

This is a table-driven test - a pattern that I don’t see used enough, but extremely common in Go codebases.

Go (hah!) spend a week implementing a small proxy, or similar networked service, or work through the fantastic Learn Go With Tests.

4. Gleam

I wanted to include something in the spirit of Erlang on this list, but wanted something a bit more “modern”. Elixir would have been the clear choice, but I decided on Gleam. Gleam is a relative newcomer, but there are some interesting ideas here - and interoperability - that make it worth exploring.

To quote the Gleam page:

The power of a type system, the expressiveness of functional programming, and the reliability of the highly concurrent, fault tolerant Erlang runtime, with a familiar and modern syntax.

Gleam applies many of the lessons learned in programming language design over the years, such as avoiding the billion dollar mistake and including a strong type system (notice a theme?)

Gleam can additionally compile to JavaScript, which is a particularly neat trick.

Here’s a small Gleam program that reads some logs in from stdin and outputs them as JSON:

import gleam/io
import gleam/iterator
import gleam/json
import gleam/string
import stdin

type LogEntry {
  Entry(level: String, message: String)
}

fn entry_to_json(entry: LogEntry) -> String {
  json.object([
    #("level", json.string(entry.level)),
    #("message", json.string(entry.message)),
  ])
  |> json.to_string
}

fn parse_to_entry(input: String) -> LogEntry {
  case string.split_once(input, on: ":") {
    Ok(parts) -> {
      Entry(level: parts.0, message: string.trim(parts.1))
    }
    Error(_) -> {
      Entry(level: "UNKNOWN", message: input)
    }
  }
}

// read logs from stdin, output as json
// input is something like "ERROR: something went wrong"
// output is something like {"level": "ERROR", "message": "something went wrong"}
pub fn main() {
  stdin.stdin()
  |> iterator.map(parse_to_entry)
  |> iterator.map(entry_to_json)
  |> iterator.each(io.print)
}

Pipelines are something every functional language should have - so much nicer than nested parens.

We can then export this program as an erlang-shipment, which compiles all the modules ready to be run by any system with Erlang:

$ gleam export erlang-shipment
  Compiling gleam_stdlib
  Compiling gleam_json
  Compiling gleeunit
  [snip]

And then execute it:

$ printf "ERROR: something went wrong\nINFO: no it didnt" | ./build/erlang-shipment/entrypoint.sh run | jq
{
  "level": "ERROR",
  "message": "something went wrong"
}
{
  "level": "INFO",
  "message": "no it didnt"
}

Gleam is a young language, being developed by a small community under the careful stewardship of Louis Pilfold. Spend a week helping out on some issues, or consider writing some packages to add to the ecosystem (at least, something more meaningful than left-pad, please).

5. Zig

I wanna really, really, really wanna “zig-a-zig”, ah
~ Spice Girls

Zig is next up. I was sorely tempted to go with a “You were expecting Rust, but it was me, Zig!” opening, but we’ll have to do with a meta-commentary instead.

To me, Zig is more interesting than Rust for three primary reasons - comptime, passing allocators, and incremental improvement of C/C++ codebases through Zig. If Rust is a “better C++”, then Zig is a “better C”, where the lack of macros etc are a feature, rather than an oversight.

Much like my earlier recommendation of Go, the surface area of Zig is very small, with no hidden control flow and a frankly tiny PEG grammar. It is very much worth your time to read the features overview - it covers pretty much all the unique aspects of Zig.

My examples here will be with Zig 0.13.0 - Zig is a reasonably unstable language (folks who write nightly Rust will be familiar) and so these examples may not run with newer or older versions.

First up, a fun comptime trick - initialising a set of static data that would be expensive at runtime (from the comptime documentation):

const first_25_primes = firstNPrimes(25);
const sum_of_first_25_primes = sum(&first_25_primes);

fn firstNPrimes(comptime n: usize) [n]i32 {
    var prime_list: [n]i32 = undefined;
    var next_index: usize = 0;
    var test_number: i32 = 2;
    while (next_index < prime_list.len) : (test_number += 1) {
        var test_prime_index: usize = 0;
        var is_prime = true;
        while (test_prime_index < next_index) : (test_prime_index += 1) {
            if (test_number % prime_list[test_prime_index] == 0) {
                is_prime = false;
                break;
            }
        }
        if (is_prime) {
            prime_list[next_index] = test_number;
            next_index += 1;
        }
    }
    return prime_list;
}

fn sum(numbers: []const i32) i32 {
    var result: i32 = 0;
    for (numbers) |x| {
        result += x;
    }
    return result;
}

test "variable values" {
    try @import("std").testing.expect(sum_of_first_25_primes == 1060);
}

We can run this with zig test --verbose-llvm-ir=llvm.ir primes.zig, and then inspect the LLVM IR for the compiled values:

$ rg '@primes\.' llvm.ir
255:@primes.first_25_primes = internal unnamed_addr constant [25 x i32] [i32 2, i32 3, i32 5, i32 7, i32 11, i32 13, i32 17, i32 19, i32 23, i32 29, i32 31, i32 37, i32 41, i32 43, i32 47, i32 53, i32 59, i32 61, i32 67, i32 71, i32 73, i32 79, i32 83, i32 89, i32 97], align 4, !dbg !10
256:@primes.sum_of_first_25_primes = internal unnamed_addr constant i32 1060, align 4, !dbg !11

This is a useful trick if you have something like precomputed hash tables, where you can amortise the cost over the life of the program by doing this at compile time. Another example is a generic queue.

Spend a week re-implementing some basic C programs in Zig, and see how far you get. Something like coreutils/basename is a small enough domain to work through.

6. Racket

Racket is next up - you didn’t think I’d complete this list without a Lisp, right? Racket is interesting in that unlike pretty much all other languages, Racket is language-oriented - its a language for making languages. Additionally, its strengths lie in its ability to convey programming principles, exemplified in the book How To Design Programs.

Given it’s a language to build languages, its no surprise that it has some in-built languages to ease development in specific domains. For example, for a web server, you might consider the web-server/insta language:

#lang web-server/insta
(define (start request)
  (response/xexpr
   '(html
     (head (title "My Blog"))
     (body (h1 "Under construction")))))

We can compile and run this (with compilation optional):

$ raco make web.rkt
$ racket compiled/web_rkt.zo
Your Web application is running at http://localhost:56355/servlets/standalone.rkt.
Stop this program at any time to terminate the Web Server.

Or what about making a language for web publishing or generating a Racket AST? The concept of language-oriented programming is fascinating to me, especially given that so much of business domains are their own language. We see new DSLs pop up all the time, such as Cedar to express access control.

Spend a week writing a language in Racket to solve a trivial problem - I like the example of stacker for a stack-based calculator. Why not implement one for Reverse Polish Notation?

7. Odin

Odin is a fascinating language that has a bunch in common with Zig, but with some very specific applications. As mentioned in the splash page, JangaFX are the creators and extensively use Odin in their applications, which are computer graphics related. As such, Odin holds a pretty unique niche in having bindings for literally all the graphics runtimes out there, from Vulkan to WebGPU.

Of course, not just games are made with Odin. A great example is the web version of Spall - a flamegraph profiler that supports their own native format as well as Google’s tracing format.

Rather than give you some demo code, I’ll instead link to Karl Zylinski’s wonderful series where they walk through making a game with Odin and raylib. Making small games is a joy. Spend a week working through that.

If you must have some code, here you go:

package game

import rl "vendor:raylib"

main :: proc() {
	rl.InitWindow(1280, 720, "A window appears!")

	for !rl.WindowShouldClose() {
		rl.BeginDrawing()
		rl.DrawText("Look ma, no engine!", 190, 200, 20, rl.BLACK)
		rl.ClearBackground(rl.RAYWHITE)
		rl.EndDrawing()
	}

	rl.CloseWindow()
}

In this example, we load the bindings to raylib and create a window, draw some text and set the background colour.

Wrap Up

We’ve covered 7 languages - Python, TypeScript, Go, Gleam, Zig, Racket and Odin - as something to explore in 7 weeks. Maybe one of these will become your daily driver, or you’ll make significant contributions to the language or community. Maybe you’ll hate them and never touch them again. At the very least, I hope you learn something from exploring them.