Scripting Languages Side-by-Side: A REPL Tour

Table of Contents

1. Introduction

Eight languages. Each one built around a different idea. The fastest way to internalize that idea is to run code, not read about it.

Each section below picks one theme per language — the thing the language is actually good at — and walks through it in three short sessions. All examples are self-contained and run at a prompt or REPL with a standard installation.

Language Core idea REPL / runner
Lua Embeddable scripting lua
Factor Concatenative stack manipulation factor or online pad
Elm Elm architecture, no runtime errors elm repl
Elixir Processes and message passing iex
Julia Multiple dispatch, numeric power julia
miniKanren Logic / relational programming scheme + miniKanren
Idris Dependent types as proof idris2
Scheme Minimal core, maximal extension guile or racket

2. Lua

Lua is a scripting language designed to be embedded. The VM is small enough to ship inside another application. The language itself is minimal: one aggregate type (tables), first-class functions, and coroutines for cooperative multitasking.

2.1. Day 1: Tables Are Everything

Lua's table is an array, a hash map, and an object — depending on how you use it.

-- Array form
local colors = {"red", "green", "blue"}
for i, v in ipairs(colors) do
  print(i, v)
end
-- 1  red
-- 2  green
-- 3  blue

-- Hash form
local point = {x = 3, y = 4}
print(math.sqrt(point.x^2 + point.y^2))  -- 5.0

-- Tables as objects via metatables
local Vector = {}
Vector.__index = Vector

function Vector.new(x, y)
  return setmetatable({x = x, y = y}, Vector)
end

function Vector:magnitude()
  return math.sqrt(self.x^2 + self.y^2)
end

local v = Vector.new(3, 4)
print(v:magnitude())  -- 5.0

2.2. Day 2: Closures and Iteration

Functions are first-class. Closures over upvalues replace most of what other languages use classes for.

-- A counter factory — the returned function closes over `count`
local function make_counter(start)
  local count = start or 0
  return function()
    count = count + 1
    return count
  end
end

local c1 = make_counter()
local c2 = make_counter(10)

print(c1(), c1(), c1())   -- 1  2  3
print(c2(), c2())          -- 11  12

-- Generic iterator: stateful function, works with for-in
local function range(from, to, step)
  step = step or 1
  local i = from - step
  return function()
    i = i + step
    if i <= to then return i end
  end
end

for n in range(1, 5) do
  io.write(n .. " ")
end
-- 1 2 3 4 5

2.3. Day 3: Coroutines

Lua's coroutines are asymmetric and cooperative. They let you write producer/consumer pipelines without threads or callbacks.

-- Producer: yields successive Fibonacci numbers
local function fib_producer()
  local a, b = 0, 1
  while true do
    coroutine.yield(a)
    a, b = b, a + b
  end
end

local co = coroutine.create(fib_producer)

-- Consume the first ten
for _ = 1, 10 do
  local ok, val = coroutine.resume(co)
  io.write(val .. " ")
end
-- 0 1 1 2 3 5 8 13 21 34

2.4. Wrapping Up Lua

The payoff: tables give you data structures, closures give you abstraction, and coroutines give you cooperative scheduling — with no runtime or garbage collection overhead beyond the Lua VM itself.

3. Factor

Factor is a concatenative, stack-based language in the lineage of Forth. Every word consumes values from the top of the stack and pushes results back. There are no named parameters; data flow is positional and explicit.

3.1. Day 1: Stack On, Stack Off

! Numbers are pushed by typing them.
! Words (functions) consume and produce stack values.

! dup copies the top; * multiplies.
5 dup *           ! => 25

! drop discards the top; swap reverses top two.
3 7 swap drop     ! => 7

! A word definition
: square ( n -- n^2 ) dup * ;

10 square         ! => 100

! stack effect comments ( inputs -- outputs ) are required
: sum-of-squares ( a b -- n )
  [ square ] bi@ + ;

3 4 sum-of-squares   ! => 25

3.2. Day 2: Painting the Fence

Quotations (anonymous words, written in brackets) are first-class. Combinators like map and filter take quotations as arguments, exactly like higher-order functions in other languages.

! A quotation is a deferred sequence of words.
{ 1 2 3 4 5 } [ dup * ] map   ! => { 1 4 9 16 25 }

! filter keeps elements where the quotation leaves t (true)
{ 1 2 3 4 5 6 } [ even? ] filter   ! => { 2 4 6 }

! reduce/fold
{ 1 2 3 4 5 } 0 [ + ] reduce       ! => 15

! Composing quotations at runtime with curry and compose
: add-n ( n -- quot ) [ + ] curry ;

10 add-n    ! => quotation that adds 10
{ 1 2 3 } swap map   ! { 1 2 3 } [ + 10 ] map => { 11 12 13 }

3.3. Day 3: Balancing on a Boat

The shuffle words — dup, drop, swap, over, rot, nip — are the grammar of stack manipulation. Learning them is like learning how to move pieces on a board.

! over: copy second item to top
!   ( a b -- a b a )
3 7 over    ! stack: 3 7 3

! rot: bring third item to top
!   ( a b c -- b c a )
1 2 3 rot   ! stack: 2 3 1

! nip: drop second item
!   ( a b -- b )
5 9 nip     ! stack: 9

! A practical pattern: keep the original while working on a copy
: clamp ( n lo hi -- n' )
  rot rot max min ;

-3 0 10 clamp   ! => 0
15 0 10 clamp   ! => 10
 5 0 10 clamp   ! => 5

3.4. Wrapping Up Factor

Factor's discipline: every function's stack effect is declared and checked at compile time. There are no hidden arguments, no implicit state. Code that reads awkwardly on first contact becomes natural once you stop reaching for variable names.

4. Elm

Elm is a purely functional language that compiles to JavaScript. Its headline claim: no runtime exceptions in practice. The Elm Architecture (Model-Update-View) structures every application the same way, making large codebases predictable.

4.1. Day 1: Handling the Basics

Elm's REPL is enough for pure expressions. Types are inferred; the compiler message when they mismatch tells you exactly what went wrong.

-- Elm REPL: elm repl

-- Basic arithmetic and strings
2 + 2                  -- 4
String.length "hello"  -- 5
String.reverse "hello" -- "olleh"

-- Lists are homogeneous
List.map (\x -> x * x) [1, 2, 3, 4, 5]
-- [1,4,9,16,25]

List.filter (\x -> modBy 2 x == 0) [1..8]
-- [2,4,6,8]

-- Records (structural)
let person = { name = "Alice", age = 30 }
person.name         -- "Alice"
{ person | age = 31 }   -- { name = "Alice", age = 31 }

4.2. Day 2: Taming Callbacks

In Elm there are no callbacks. Side effects are represented as Cmd values returned from the update function. The runtime executes them and delivers results as Msg values — the loop is explicit and typed.

-- A minimal counter application
module Main exposing (main)

import Browser
import Html exposing (Html, button, div, text)
import Html.Events exposing (onClick)

type alias Model = Int

type Msg = Increment | Decrement | Reset

init : Model
init = 0

update : Msg -> Model -> Model
update msg model =
  case msg of
    Increment -> model + 1
    Decrement -> model - 1
    Reset     -> 0

view : Model -> Html Msg
view model =
  div []
    [ button [ onClick Decrement ] [ text "-" ]
    , text (String.fromInt model)
    , button [ onClick Increment ] [ text "+" ]
    , button [ onClick Reset ] [ text "reset" ]
    ]

main =
  Browser.sandbox { init = init, update = update, view = view }

The update function is a pure function of Msg and Model. Every state transition is a value in the Msg type. Unhandled messages are a compile error.

4.3. Day 3: It's All a Game

Random and Time are effects. They arrive through Cmd and Sub values, not imperative calls. Here is a dice roller to see the pattern.

module Dice exposing (main)

import Browser
import Html exposing (..)
import Html.Events exposing (onClick)
import Random

type alias Model = { face : Int }

type Msg = Roll | NewFace Int

init : () -> ( Model, Cmd Msg )
init _ = ( { face = 1 }, Cmd.none )

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
  case msg of
    Roll ->
      ( model, Random.generate NewFace (Random.int 1 6) )
    NewFace n ->
      ( { model | face = n }, Cmd.none )

view : Model -> Html Msg
view model =
  div []
    [ h1 [] [ text (String.fromInt model.face) ]
    , button [ onClick Roll ] [ text "Roll" ]
    ]

main =
  Browser.element
    { init = init
    , update = update
    , view = view
    , subscriptions = \_ -> Sub.none
    }

4.4. Wrapping Up Elm

Every piece of state lives in Model. Every change is named in Msg. The compiler enforces exhaustive pattern matching. The result: refactoring is safe because the compiler catches every case you forgot.

5. Elixir

Elixir runs on the Erlang VM (BEAM). Its model: lightweight processes communicating by message passing, supervised by trees of supervisors. Individual processes crash; the supervisor restarts them. The application keeps running.

5.1. Day 1: Laying a Great Foundation

The pipeline operator (|>) chains transformations without nested calls. Pattern matching destructures in function heads.

# iex — Elixir's interactive shell

# Pipeline: result flows left to right
"hello world"
|> String.split()
|> Enum.map(&String.capitalize/1)
|> Enum.join(" ")
# => "Hello World"

# Pattern matching in function definitions
defmodule Geometry do
  def area({:circle, r}),    do: :math.pi() * r * r
  def area({:rect, w, h}),   do: w * h
  def area({:square, s}),    do: s * s
end

Geometry.area({:circle, 5})    # => 78.53981633974483
Geometry.area({:rect, 3, 4})   # => 12
Geometry.area({:square, 7})    # => 49

5.2. Day 2: Controlling Mutations

Elixir data is immutable. "Mutation" means rebinding a name to a new value. Processes hold state by tail-calling themselves with the new value.

# A simple key-value store as a GenServer
defmodule KVStore do
  use GenServer

  # Client API
  def start_link(initial \\ %{}) do
    GenServer.start_link(__MODULE__, initial, name: __MODULE__)
  end

  def put(key, value), do: GenServer.cast(__MODULE__, {:put, key, value})
  def get(key),        do: GenServer.call(__MODULE__, {:get, key})

  # Server callbacks
  @impl true
  def init(state), do: {:ok, state}

  @impl true
  def handle_cast({:put, key, value}, state) do
    {:noreply, Map.put(state, key, value)}
  end

  @impl true
  def handle_call({:get, key}, _from, state) do
    {:reply, Map.get(state, key), state}
  end
end

{:ok, _pid} = KVStore.start_link()
KVStore.put(:name, "Alice")
KVStore.get(:name)   # => "Alice"

The state lives inside the process. Nothing else can touch it directly. Concurrent reads and writes serialize through the mailbox.

5.3. Day 3: Spawning and Respawning

Supervisors watch processes and restart them on failure. The one_for_one strategy restarts only the crashed child; one_for_all restarts all siblings.

# Spawn a bare process and send it a message
pid = spawn(fn ->
  receive do
    {:greet, name} -> IO.puts("Hello, #{name}!")
  end
end)

send(pid, {:greet, "world"})
# Hello, world!

# A supervised child spec (for use in a Supervisor)
defmodule Worker do
  use GenServer

  def start_link(arg), do: GenServer.start_link(__MODULE__, arg)

  @impl true
  def init(arg) do
    IO.puts("Worker started with #{inspect(arg)}")
    {:ok, arg}
  end
end

# In an application supervisor:
# children = [
#   {Worker, :initial_state}
# ]
# Supervisor.start_link(children, strategy: :one_for_one)

5.4. Wrapping Up Elixir

The process boundary is the unit of fault isolation. Each GenServer is a concurrent, independently-supervised state machine. Supervision trees make "let it crash" a valid recovery strategy.

6. Julia

Julia targets scientific computing. Its central mechanism is multiple dispatch: the runtime selects which method to call based on the types of all arguments, not just the first. This makes it easy to add behavior for new type combinations without modifying existing code.

6.1. Day 1: Multiple Dispatch

# julia — start the REPL with `julia`

# Methods specialize on argument types
area(r::Float64) = π * r^2                    # circle
area(w::Float64, h::Float64) = w * h          # rectangle

area(5.0)       # => 78.53981633974483
area(3.0, 4.0)  # => 12.0

# Numeric tower: Int, Float64, Complex, Rational all compose
area(5)         # dispatches to area(::Float64) after promotion? No — Int != Float64
area(5.0 + 0im) # Complex — different specialization

# See which method was selected
@which area(5.0)
# area(r::Float64) ...

6.2. Day 2: Vectorized Operations

Julia's broadcast operator (.) lifts scalar operations to arrays without writing explicit loops.

xs = 1:10

# Broadcasting: apply element-wise
xs .^ 2                  # [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

# No allocation with in-place broadcast
result = zeros(10)
result .= xs .* 2        # [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]

# Comprehensions
squares = [x^2 for x in 1:5]       # [1, 4, 9, 16, 25]
evens   = [x for x in 1:20 if iseven(x)]  # [2, 4, 6, 8, ..., 20]

# Linear algebra is in the standard library
using LinearAlgebra
A = [1 2; 3 4]
det(A)          # => -2.0
inv(A)          # => [-2.0 1.0; 1.5 -0.5]
A * A           # => [7 10; 15 22]

6.3. Day 3: Type System and Performance

Julia compiles to native code via LLVM. Annotating types is optional for correctness but essential for performance. The macro @code_typed shows what the compiler inferred.

# A type-annotated struct
struct Point{T<:Real}
  x::T
  y::T
end

distance(a::Point, b::Point) =
  sqrt((a.x - b.x)^2 + (a.y - b.y)^2)

p1 = Point(0.0, 0.0)
p2 = Point(3.0, 4.0)
distance(p1, p2)   # => 5.0

# @benchmark from BenchmarkTools shows ns-level timing
# For now, @time gives allocation + timing info
function sum_squares(n::Int)
  total = 0.0
  for i in 1:n
    total += i^2
  end
  total
end

@time sum_squares(1_000_000)
# 0.000003 seconds (1 allocation: 16 bytes)
# => 3.333338333335e11

6.4. Wrapping Up Julia

Multiple dispatch makes Julia's libraries composable without inheritance hierarchies. A type defined in one package works transparently with algorithms defined in another — as long as the method signatures match.

7. miniKanren

miniKanren is a logic programming system designed to be embedded in a host language. The canonical embedding is in Scheme. Relations are written as goals; the runtime searches for values that satisfy all goals simultaneously.

7.1. Day 1: Unified Theories of Code

The fundamental operation is unify: two terms unify if they can be made equal under some substitution.

;;; Using the miniKanren embedded in Racket (the `minikanren` package)
;;; or the reference implementation: https://github.com/miniKanren/miniKanren

(require minikanren)

;; run* returns all solutions; q is the query variable
(run* (q)
  (== q 5))
;; => (5)

;; Two variables that must agree
(run* (x y)
  (== x 3)
  (== y x))
;; => ((3 3))

;; Fresh introduces a new logic variable
(run* (q)
  (fresh (x y)
    (== x 1)
    (== y 2)
    (== q (list x y))))
;; => ((1 2))

;; conde is disjunction: try each clause
(run* (q)
  (conde
    [(== q 'apple)]
    [(== q 'orange)]
    [(== q 'banana)]))
;; => (apple orange banana)

7.2. Day 2: Mixing the Logical and Functional

Relations are functions that return goals. They compose like ordinary functions. The relation can be run in any direction.

;;; appendo relates a list to its append of two parts.
;;; It works forwards, backwards, and with unknowns.

(define (appendo l s out)
  (conde
    [(== l '()) (== s out)]
    [(fresh (a d res)
       (== l (cons a d))
       (== out (cons a res))
       (appendo d s res))]))

;; Forward: append '(1 2) and '(3 4)
(run* (q)
  (appendo '(1 2) '(3 4) q))
;; => ((1 2 3 4))

;; Backward: what prepended to '(3 4) gives '(1 2 3 4)?
(run* (q)
  (appendo q '(3 4) '(1 2 3 4)))
;; => ((1 2))

;; Enumerate all splits of '(a b c)
(run* (x y)
  (appendo x y '(a b c)))
;; => (() (a b c))
;;    ((a) (b c))
;;    ((a b) (c))
;;    ((a b c) ())

7.3. Day 3: Writing Stories with Logic

The same relational style works for symbolic domains: type checking, program synthesis, parsing.

;;; A tiny relational interpreter for arithmetic expressions.
;;; eval-expo relates an expression, an environment, and a value.

(define (lookup-env x env out)
  (fresh (k v rest)
    (== env (cons (cons k v) rest))
    (conde
      [(== k x) (== v out)]
      [(=/= k x) (lookup-env x rest out)])))

(define (eval-expo expr env out)
  (conde
    ;; numbers self-evaluate
    [(numbero expr) (== expr out)]
    ;; symbols look up the environment
    [(symbolo expr) (lookup-env expr env out)]
    ;; addition
    [(fresh (e1 e2 v1 v2)
       (== expr `(+ ,e1 ,e2))
       (eval-expo e1 env v1)
       (eval-expo e2 env v2)
       (pluso v1 v2 out))]))

;; What expression evaluates to 5 in {x: 2, y: 3}?
(run 3 (q)
  (eval-expo q '((x . 2) (y . 3)) 5))
;; => (5  (+ x y)  (+ y x) ...)

7.4. Wrapping Up miniKanren

Relations run in any direction. The same appendo that appends lists can split them. The same eval-expo that evaluates programs can synthesize them. That bidirectionality is the point.

8. Idris

Idris is a dependently-typed language. Types can depend on values, so a type can express "a list of exactly n elements" or "a proof that a <= b". The type checker is a proof checker.

8.1. Day 1: Types That Carry Values

-- idris2 -- start the REPL with `idris2`

-- Vect n a: a list that knows its length at compile time
import Data.Vect

-- The type says: takes a Vect of length n, returns a Vect of length n
-- Can't accidentally return a different-length vector
myReverse : Vect n a -> Vect n a
myReverse [] = []
myReverse (x :: xs) = myReverse xs ++ [x]

myReverse [1, 2, 3]   -- => [3, 2, 1] : Vect 3 Integer

-- zip requires both vectors to have the same length n
-- Mismatched lengths are a compile error, not a runtime error
myZip : Vect n a -> Vect n b -> Vect n (a, b)
myZip [] [] = []
myZip (x :: xs) (y :: ys) = (x, y) :: myZip xs ys

myZip [1, 2, 3] ['a', 'b', 'c']
-- => [(1, 'a'), (2, 'b'), (3, 'c')] : Vect 3 (Integer, Char)

8.2. Day 2: Proofs as Programs (the Curry-Howard Correspondence)

A type is a proposition. A value of that type is a proof. Writing a function with a given type is proving the corresponding theorem.

-- The Nat type: Peano natural numbers
-- Z : Nat        (zero)
-- S n : Nat      (successor of n)

-- plusCommutative : for all m n, m + n = n + m
-- The Idris standard library proves this; here is the structure

-- A simpler lemma: plus Z n = n
plusZeroLeft : (n : Nat) -> plus Z n = n
plusZeroLeft n = Refl   -- Z + n reduces to n by definition

-- plus (S m) n = S (plus m n)  -- also definitional
-- So the full commutativity proof is by induction on m:
myPlusComm : (m : Nat) -> (n : Nat) -> plus m n = plus n m
myPlusComm Z n = sym (plusZeroRight n)
  where
    plusZeroRight : (k : Nat) -> plus k Z = k
    plusZeroRight Z = Refl
    plusZeroRight (S k) = cong S (plusZeroRight k)
myPlusComm (S m) n =
  rewrite myPlusComm m n in
  sym (plusSuccRight n m)
  where
    plusSuccRight : (k : Nat) -> (j : Nat) -> plus k (S j) = S (plus k j)
    plusSuccRight Z j = Refl
    plusSuccRight (S k) j = cong S (plusSuccRight k j)

8.3. Day 3: Practical Dependent Types

Dependent types are not only for proofs. They make APIs safer: a head function that can only be called on non-empty lists.

-- safeHead: the type guarantees the list is non-empty
safeHead : Vect (S n) a -> a
safeHead (x :: _) = x

-- Calling it on an empty vector is a type error at compile time:
-- safeHead []   -- ERROR: Type mismatch

safeHead [10, 20, 30]   -- => 10

-- A matrix type: Vect rows (Vect cols a)
Matrix : Nat -> Nat -> Type -> Type
Matrix rows cols a = Vect rows (Vect cols a)

-- Transpose requires rows and cols to swap
transpose : Matrix m n a -> Matrix n m a
transpose [] = replicate _ []
transpose (row :: rows) = zipWith (::) row (transpose rows)

m : Matrix 2 3 Integer
m = [[1, 2, 3], [4, 5, 6]]

transpose m   -- => [[1, 4], [2, 5], [3, 6]] : Matrix 3 2 Integer

8.4. Wrapping Up Idris

The move from Idris 1 to Idris 2 (based on QTT, Quantitative Type Theory) adds linearity. A value with multiplicity 1 can be used exactly once — the type system can track resource consumption. Same idea as Rust's borrow checker, but expressed in types rather than a separate analysis.

9. Scheme

Scheme is a minimalist Lisp. The R7RS standard fits in 80 pages. That minimalism is a design choice: the language is small enough to understand completely, and the macro system is powerful enough to build anything on top of it.

9.1. Day 1: The Core: Lambda, Apply, Quasiquote

;;; guile or racket -- scheme --r7rs

;; Everything is an expression
(+ 1 2)              ; => 3

;; Lambda is the only way to make a function
(define square (lambda (x) (* x x)))
(square 7)           ; => 49

;; define is syntax sugar for lambda binding
(define (cube x) (* x x x))
(cube 3)             ; => 27

;; Quasiquote builds list structure with splicing
(define name "world")
`(hello ,name)       ; => (hello world)

(define xs '(1 2 3))
`(before ,@xs after) ; => (before 1 2 3 after)

;; Higher-order: map and filter from the standard
(map square '(1 2 3 4 5))         ; => (1 4 9 16 25)
(filter odd? '(1 2 3 4 5 6))      ; => (1 3 5)
(fold-right + 0 '(1 2 3 4 5))     ; => 15

9.2. Day 2: Tail Calls and Accumulator Style

Scheme mandates proper tail-call optimization. A function that tail-calls itself consumes no additional stack. Iteration is expressed as recursion.

;; Naive recursion: grows the stack
(define (sum-naive lst)
  (if (null? lst)
      0
      (+ (car lst) (sum-naive (cdr lst)))))

;; Tail-recursive: the accumulator carries the result
(define (sum lst acc)
  (if (null? lst)
      acc
      (sum (cdr lst) (+ acc (car lst)))))  ; tail position

(define (sum-list lst) (sum lst 0))

(sum-list '(1 2 3 4 5))   ; => 15

;; Named let is idiomatic for loops with accumulators
(define (factorial n)
  (let loop ((i n) (acc 1))
    (if (<= i 1)
        acc
        (loop (- i 1) (* acc i)))))

(factorial 10)   ; => 3628800

9.3. Day 3: Macros via syntax-rules

syntax-rules is a pattern-based macro system. Patterns match on the structure of a form; templates produce new code. No parenthesis counting required.

;; while loop: not in standard Scheme, but trivial to add
(define-syntax while
  (syntax-rules ()
    [(while condition body ...)
     (let loop ()
       (when condition
         body ...
         (loop)))]))

(let ((i 0) (sum 0))
  (while (< i 10)
    (set! sum (+ sum i))
    (set! i (+ i 1)))
  sum)
;; => 45

;; swap!: exchange two variables
(define-syntax swap!
  (syntax-rules ()
    [(swap! a b)
     (let ((tmp a))
       (set! a b)
       (set! b tmp))]))

(let ((x 1) (y 2))
  (swap! x y)
  (list x y))
;; => (2 1)

;; and/or short-circuit — standard, but instructive to see the expansion:
;; (and a b c) => (if a (if b c #f) #f)
;; (or  a b c) => (let ((t a)) (if t t (or b c)))

9.4. Wrapping Up Scheme

Seven essential forms: lambda, define, if, cond, let, begin, quote. Everything else is a library or a macro. That simplicity is what makes Scheme the lingua franca of programming language research: a new idea fits in a page.

10. Resources