Scheme vs Haskell for Academic Functional Programming: I TAed Both Courses and Here’s What I Learned

The Setup: Two Semesters, Two Languages, Very Different Pain

The thing that surprised me most wasn’t which language students struggled with — it was when they struggled. TAing an intro CS course using Racket, I watched students hit a wall on recursion around week three, then suddenly click and start writing elegant list processors by week five. TAing the PL theory course using Haskell the following semester, the wall never fully disappeared. Students hit the type system in week one and were still negotiating with it in week ten. Same concepts — higher-order functions, lazy evaluation, algebraic data types — wildly different timelines for comprehension.

Scheme (Racket specifically, which is the dialect most academic courses actually use now) has a brutal honesty to it. You open DrRacket, type (define (fact n) (if (= n 0) 1 (* n (fact (- n 1))))), and it runs. No ceremony. The parentheses are annoying but they’re regular — once students get that everything is a list, they have a mental model that actually scales. Haskell forces a different deal: before you write meaningful code, you need to understand why the compiler is rejecting your code. That’s a legitimate pedagogical choice, but it front-loads a massive cognitive tax.

The core tension I kept observing is that Scheme gets out of your way so you can think about the concept you’re actually trying to learn. Haskell makes the type system the concept, whether you want it to be or not. For a PL theory course where type systems are literally the curriculum, that’s fine — actually good. For an intro course where you want students building intuition about recursion and higher-order functions fast, it’s a liability. I watched students in the Haskell course spend 40 minutes debugging a type error that had nothing to do with the algorithm they were trying to implement.

-- Haskell: the type error that ate an afternoon
-- Student wanted a list of mixed numbers, got this instead:
myList = [1, 2.5, 3]
-- ERROR: No instance for (Fractional Int) arising from the literal '2.5'

-- Scheme equivalent just works:
(define my-list '(1 2.5 3))

If you’re choosing a language for a course, thesis work, or self-study, the question isn’t which language is “better” — it’s what you want the friction to teach. Scheme friction is algorithmic: students struggle with the algorithm, not the language. Haskell friction is systemic: the language itself is the lesson. For self-study, I’d recommend Scheme first if you’re new to functional programming, then Haskell once you have a year of functional thinking under your belt. For thesis work in PL theory, type theory, or formal verification, Haskell’s type system isn’t friction — it’s the point. For tools that support your broader academic or dev workflow, check out our guide on Essential SaaS Tools for Small Business in 2026.

One practical note: Racket ships with a full IDE, a package manager (raco pkg install), and a documentation system that’s genuinely good. Haskell’s toolchain in 2024 means choosing between Stack and Cabal, debugging GHC version conflicts, and accepting that setting up a working environment might take a full afternoon. That’s not a dealbreaker for grad students, but for a 200-person intro course it’s a real operational cost that course designers consistently underestimate.

Getting Both Installed Without Losing Your Mind

The thing that actually matters here isn’t which language is “easier to install” — it’s what happens in the first five minutes after installation, because that’s when students form their lasting impressions. Racket wins that battle almost unfairly. You run brew install racket on macOS or grab the installer from racket-lang.org, and DrRacket launches with a definition area at the top and a REPL at the bottom. No configuration. No choosing between toolchains. A student who’s never touched a functional language can be evaluating (+ 1 2) within three minutes of downloading. That IDE isn’t a toy either — it has a real stepper debugger that’s genuinely useful for teaching lambda evaluation semantics.

Haskell’s install story used to be a disaster, and the Haskell Platform made it worse by shipping stale versions and polluting your system paths. In 2024, GHCup is the answer, full stop:

# This installs GHCup, which then manages GHC, cabal, stack, and HLS
curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh

# After install, check what you got
ghcup list

# Recommended for academic use: pin GHC 9.4.x or 9.6.x — not bleeding edge
ghcup install ghc 9.6.4
ghcup set ghc 9.6.4

I tell students to ignore cabal vs stack entirely for the first two weeks. That religious war has burned too many beginners — both tools are solving dependency isolation problems that don’t exist yet when you’re just learning type inference. Fire up ghci, load a file with :l MyFile.hs, and you’re done. The GHCi autocomplete is legitimately impressive — it tab-completes type signatures, module names, and even suggests fixes for type errors in some cases. Racket’s REPL drops you in faster (3 seconds versus the 8-12 seconds GHCi takes to initialize on first launch), but GHCi’s :type and :info commands have no real equivalent in the Racket REPL and are worth the wait.

The #lang directive is Racket’s single biggest pedagogical advantage and Haskell has nothing remotely like it. At the top of any Racket file, you declare which language semantics you want:

#lang racket
; Full Racket — batteries included, mutable state available, full macro system

#lang sicp
; SICP-compatible dialect — pairs behave like the textbook, no extras
; Install it once: raco pkg install sicp

#lang typed/racket
; Gradual typing — same syntax, now with type annotations enforced

#lang htdp/bsl
; "Beginning Student Language" — errors are rewritten for novices

This matters enormously if you’re teaching from a specific textbook. Running SICP exercises in #lang sicp means cons, car, and cdr behave exactly as Abelson and Sussman describe — no surprises from Racket’s extended pair semantics leaking in. Switching to #lang typed/racket in week six of a course lets students experience static types without learning a new syntax. The whole thing is a language tower built for exactly this use case. Haskell is one language with one set of semantics, which is both its strength (consistency) and its limitation (you can’t turn off the type system for introductory exercises).

One genuine gotcha with GHCup: if you’re on an M1/M2/M3 Mac, make sure you’re pulling GHC 9.2.1 or newer — earlier versions had ARM support issues that produced cryptic linker errors. On Linux with glibc older than 2.17 (some university servers still run CentOS 7 derivatives), the prebuilt GHC binaries won’t work and you’ll need to build from source or use a Docker image. Racket’s installers are statically linked and just work on everything back to Ubuntu 18.04 and macOS 11. That’s not a minor point when you’re handing out setup instructions to thirty students on heterogeneous hardware.

Where Scheme Wins: The First Two Weeks of a Functional Course

The thing that catches most instructors off guard is how much of Week 1 in a functional programming course gets eaten by tooling rather than concepts. I’ve seen students spend a full lab session fighting GHC error messages before they’ve written a single meaningful function. Scheme sidesteps this entirely — not because it’s dumbed down, but because the distance from idea to running code is measured in lines, not configuration files.

A recursive Fibonacci in Scheme looks like this:

; Students write this in the first 20 minutes of Day 1
(define (fib n)
  (if (< n 2)
      n
      (+ (fib (- n 1)) (fib (- n 2)))))

(fib 10) ; => 55

That’s it. No imports. No type signatures. No module Main where. No IO () to explain. The student’s mental model is: define a thing, call the thing, see what happens. DrRacket’s REPL makes this loop sub-second — you define a function in the definitions pane, hit Run, and call it interactively below. I’ve watched students who’ve never touched a functional language build a working tree traversal by the end of a two-hour lab, because the feedback surface is so tight.

Dynamic typing gets a bad reputation in production engineering circles, and fairly so. But for teaching recursion and higher-order functions, it’s genuinely an asset. When you’re trying to explain that map takes a function and a list, the last thing you want is a student staring at (a -> b) -> [a] -> [b] wondering what the arrows mean before they’ve seen a lambda. In Scheme, you just show them:

(map (lambda (x) (* x x)) '(1 2 3 4 5))
; => (1 4 9 16 25)

; Then immediately show them named functions work too
(define (square x) (* x x))
(map square '(1 2 3 4 5))
; => (1 4 9 16 25)

The concept lands. The type annotation noise doesn’t drown it. You can introduce type thinking later, once the student has an intuition for what’s actually happening with the data.

If you’re running a course off SICP — and you probably should be, it’s still one of the sharpest introductions to computational thinking ever written — the #lang sicp package in Racket makes modern tooling work with the book’s examples verbatim. Install it once:

# In DrRacket's package manager, or from terminal:
raco pkg install sicp

# Then any file starts with:
#lang sicp

; And SICP's examples run without modification
(define (make-account balance)
  (define (withdraw amount)
    (if (>= balance amount)
        (begin (set! balance (- balance amount)) balance)
        "Insufficient funds"))
  ...)

No translation layer, no “the book uses a slightly different dialect” disclaimers. Students can type examples straight from the PDF and they run.

Tail call optimization is guaranteed by the R7RS spec — not a compiler hint, not a best-effort optimization, not something you hope your runtime does. This matters pedagogically because you can teach it as a reliable concept. Show students the stack-blowing recursive version, then show them the tail-recursive accumulator version, and explain that Scheme promises the second one runs in constant stack space. In Haskell you’d be explaining laziness and why it’s different from TCO; in most other languages you’d be hedging about whether the JIT will actually optimize it. Here you just run it:

; This blows the stack eventually — and you can show students exactly when
(define (fib-naive n)
  (if (< n 2) n (+ (fib-naive (- n 1)) (fib-naive (- n 2)))))

; This runs in O(1) stack space — guaranteed by spec, not by luck
(define (fib-tail n acc1 acc2)
  (if (= n 0)
      acc1
      (fib-tail (- n 1) acc2 (+ acc1 acc2))))

(define (fib n) (fib-tail n 0 1))

(fib 1000000) ; Returns instantly, no stack overflow

The guarantee is what makes it teachable. You're not saying "this usually works" — you're saying "the language spec requires this, here's why, and here's the proof that it does what we claim." That's a rare gift in systems education.

Where Haskell Wins: Once Students Need to Think in Types

The moment that shifted my thinking about teaching Haskell was watching a student model a grading system. In Scheme they'd write a function, run it, get a wrong answer, and debug. In Haskell, they couldn't even compile until they'd decided: does a missing grade produce a Nothing, a Left "no submission", or a separate Ungraded constructor? That argument with the compiler — before a single test ran — is the design process. You can't skip it.

Algebraic data types make domain modeling unavoidable in the best way. When a student writes:

data Result a
  = Success a
  | Failure String
  | Pending
  deriving (Show, Eq)

grade :: Submission -> Result Score
grade sub
  | isEmpty sub  = Failure "no content"
  | isLate sub   = Pending
  | otherwise    = Success (evaluate sub)

...they've made three decisions explicit that would've been implicit null-checks or magic numbers in Python. Maybe and Either don't just prevent null errors — they force you to enumerate what can go wrong before you write the happy path. That habit transfers to every language they use after graduation.

Type inference is genuinely good in GHC, but the interesting thing isn't that you don't have to write annotations — it's what happens when inference goes wrong. GHC's error messages, especially post-GHC 9.4, are specific enough to point at the actual conceptual mistake. If a student tries to use a Maybe Int where an Int is expected, the error says exactly that, with the inferred types shown. Compare that to a Python AttributeError: 'NoneType' object has no attribute 'split' at runtime, three call frames deep. The compiler is doing what a code review should do, except it's available at 2am before a deadline.

Lazy evaluation by default will genuinely break intuitions students built in imperative languages. The first time a student evaluates take 10 [1..] in GHCi and gets [1,2,3,4,5,6,7,8,9,10] back instantly, they don't believe it worked:

-- This doesn't hang. It terminates.
take 10 [1..]
-- [1,2,3,4,5,6,7,8,9,10]

-- Neither does this
take 5 (filter even [1..])
-- [2,4,6,8,10]

-- This is where it gets interesting for algorithms courses
fibs = 0 : 1 : zipWith (+) fibs (tail fibs)
take 15 fibs
-- [0,1,1,2,3,5,8,13,21,34,55,89,144,233,377]

The fibs definition is self-referential and it works. That's not a trick — it's a direct consequence of laziness, and understanding it connects to memoization, stream processing, and eventually to the denotational semantics content that shows up in theory courses.

Typeclasses are where Haskell earns its reputation in academic CS specifically. Functor, Foldable, and Traversable aren't just abstractions for their own sake — they're the concrete, runnable version of category theory concepts students will see in their PL theory or formal methods courses. When you've implemented fmap for a custom tree type, the category-theoretic definition of a functor stops being abstract nonsense. Scheme has duck typing: if it has a car and a cdr, treat it like a list. That works, but it doesn't teach you why the abstraction boundary exists.

The tooling situation has improved enough that it's worth calling out specifically. HLS (Haskell Language Server) with VS Code — using the haskell extension from the Haskell.org team — gives you type-on-hover that's one of the most educational features I've seen in any language tooling. You hover over any subexpression and see its type. For a student learning to read types, being able to mouse over foldr's first argument and see (a -> b -> b) in context is worth more than a dozen textbook examples. Getting HLS running without Stack or Cabal issues is still a first-hour problem you'll need to solve for students, but once it's working, it's legitimately excellent.

The Honest Rough Edges of Each

The thing that catches most students off guard isn't the syntax difference — it's that Scheme will happily run broken code right up until the moment it explodes at runtime. I've watched students submit working-looking homework that crashes on edge cases a type checker would have flagged immediately. For a 50-line assignment, that's fine. For a 500-line interpreter project, you're spending hours hunting down bugs that Haskell would surface in seconds.

Haskell's monad problem is real and I won't sugarcoat it. The academic documentation tradition of defining a monad as "a monoid in the category of endofunctors" is genuinely harmful to beginners — it's technically correct and pedagogically useless. The IO monad specifically breaks people because it looks like a container but behaves like a sequencing mechanism, and the mental model students carry from Scheme (where side effects just... happen) is exactly wrong here. The dropout rate in courses where IO monad is introduced without a dedicated week of scaffolding is noticeably high.

-- This confuses everyone the first time
main :: IO ()
main = do
  let x = 42          -- pure binding, no IO
  y <- getLine        -- IO binding, very different
  putStrLn (show x)   -- x works fine here
  -- but you can't use y in a pure context without threading IO through everything

Scheme's error messages are some of the least helpful I've seen in any language still used in serious contexts. car: contract violation, expected: pair?, given: '() tells you what broke but not where your logic went wrong. There's no type context, no call-site inference — just a crash with a partial stack trace. Racket improves on this significantly, but vanilla R7RS Scheme in environments like MIT Scheme or Guile gives you almost nothing to work with when debugging recursive functions three calls deep.

Haskell's compiler errors have a reputation for being impenetrable, but I think that reputation is 5 years out of date. GHC 9.x errors are verbose but accurate — a Could not deduce (Show a) arising from a use of 'putStrLn' actually tells you exactly what constraint is missing and where. The real issue is volume: a type mismatch in Haskell produces 20 lines where Python produces 1. You need to train yourself to read just the first "Expected type / Actual type" block and ignore the rest until you've solved that.

-- GHC error you'll see constantly as a beginner:
-- Could not deduce (Num String) arising from a use of '+'
-- This is dense but accurate — you tried to add a String to something
-- The fix is always in that first "arising from" line

add :: String -> String -> String
add x y = x + y  -- GHC is right, you probably wanted (++)

Package management is where Haskell genuinely earns complaints. Hackage has over 16,000 packages, which sounds great until cabal can't solve your dependency constraints and just errors out with no actionable suggestion. The specific trap I've hit multiple times: cabal install --lib some-package installs into a global package environment that then silently conflicts with your project-local build. Stack avoids this with its resolver model, but then you're learning Stack's mental model on top of Haskell's. For academic use, I now tell students to always start with cabal init and never touch cabal install --lib directly.

# The trap: this installs globally and breaks project builds
cabal install --lib text

# Do this instead — always work inside a project
cabal init my-project
cd my-project
# add dependencies to my-project.cabal under build-depends:
# build-depends: base ^>=4.17, text ^>=2.0
cabal build  # resolves inside the project sandbox

Head-to-Head: Scheme vs Haskell by Use Case

The question I hear most often from CS grad students isn't "which is better" — it's "which one should I actually use for this specific thing." That's the right question, and the answer genuinely differs depending on whether you're teaching freshmen or proving theorems.

The Quick Matrix

Use Case Scheme / Racket Haskell
Intro FP course ✅ Strong choice ⚠️ Steep for beginners
PL theory / research ⚠️ Good for prototyping ✅ GHC is a research artifact itself
Thesis implementation ⚠️ Fast iteration, weak guarantees ✅ Types as proofs
Self-study from SICP ✅ It's literally the target language ❌ Wrong book, wrong tool
Industry-adjacent projects ❌ Small job market ✅ Cardano, Mercury, IOHK

The Macro System Gap Is Wider Than People Admit

Scheme macros genuinely feel like they belong in the language. syntax-rules gives you pattern-based hygienic macros with almost no ceremony, and syntax-case unlocks procedural macro transformations without abandoning hygiene. You write macros the same way you write functions — the mental model barely shifts. Haskell's Template Haskell is a different beast entirely. It's powerful in the sense that a jackhammer is powerful: you can do anything, but the syntax is genuinely hostile.

; Scheme: define a simple swap! macro with syntax-rules
(define-syntax swap!
  (syntax-rules ()
    [(_ a b)
     (let ([tmp a])
       (set! a b)
       (set! b tmp))]))

-- Haskell: generate record accessors with Template Haskell
-- Just to get a feel for the noise level:
{-# LANGUAGE TemplateHaskell #-}
import Language.Haskell.TH

makeAccessor :: Name -> DecsQ
makeAccessor name = do
  let accName = mkName $ "get" ++ nameBase name
  funD accName [clause [] (normalB (varE name)) []]

The Scheme version is something a week-two student can read. The TH version requires knowing what DecsQ, funD, clause, and normalB mean — none of which are obvious from the type signatures. Racket pushes this even further with #lang and the macro system underneath it; you can literally build a new language syntax as a library. That's not hyperbole — that's how Typed Racket and Rhombus are implemented.

Haskell's Concurrency Story Is Legitimately Impressive

GHC's runtime gives you green threads that are cheap enough to spawn hundreds of thousands of them, and STM (Software Transactional Memory) that actually composes — which is something lock-based concurrency never gave us. I've written concurrent Haskell where the STM block reads like a sequential spec and the runtime handles all the retry logic. It's one of those things that sounds like marketing until you actually use it.

import Control.Concurrent.STM

-- Transfer between accounts atomically — no locks, composes safely
transfer :: TVar Int -> TVar Int -> Int -> STM ()
transfer from to amount = do
  fromBal <- readTVar from
  -- STM automatically retries if fromBal < amount (retry blocks until state changes)
  check (fromBal >= amount)
  modifyTVar from (subtract amount)
  modifyTVar to (+ amount)

-- This composes with other STM operations trivially
-- Try doing that with mutexes without risking deadlock

Racket has places (separate heaps, message-passing like Erlang) and futures (parallel evaluation with some caveats about when they actually run in parallel vs inline). The Racket docs are honest about the limitations — futures only truly parallelize when the work is "future-safe," which excludes a bunch of operations involving the GC and certain I/O. It works, but you spend more time reasoning about whether your code will actually parallelize than you do in Haskell where lightweight threads just work and STM gives you safe shared state.

Where Each One Wins Unambiguously

  • SICP self-study: Use MIT Scheme or Racket with #lang sicp. There's no debate here. SICP's entire pedagogical model is built around Scheme's evaluation model.
  • Type-driven development or formal methods research: Haskell. The combination of GADTs, type families, and -XDataKinds in GHC 9.x lets you encode invariants that would require a proof assistant in other languages. You're essentially writing lightweight Agda.
  • Building a DSL or language prototype: Racket. The #lang mechanism and macro tower mean you can ship a working DSL in a long afternoon.
  • Joining a functional programming team after grad school: Haskell. Mercury (fintech), Cardano/IOHK (blockchain), and a cluster of smaller functional-first companies actively hire Haskell devs. Racket jobs are rare enough that you're mostly looking at Clojure as the "adjacent" path.

The honest trade-off summary: Scheme gets out of your way so you can think about the problem; Haskell gets in your way in ways that force you to think more carefully about the problem. For teaching and exploration, the former is a feature. For research implementations where correctness matters, the latter is too.

When to Pick Scheme

When Scheme Is the Right Call

The thing that surprises most professors switching from Haskell to Scheme for intro courses: students write a working recursive function in the first 20 minutes. Not because Scheme is "simpler" in some abstract sense, but because the syntax has essentially no surface area. There's nothing to unlearn. A student who has never written code before reads (+ 1 2) and immediately understands it — operator first, then operands. That consistency doesn't break anywhere in the language.

If your course is built around SICP or The Little Schemer, the choice is obvious — these books were written assuming Scheme as the substrate, and the pedagogy is tightly coupled to how Scheme actually works. Trying to teach SICP concepts in Haskell is like teaching someone to cook using someone else's kitchen; the ideas translate but you spend half the lecture explaining where the knives are. When Friedman writes "the Law of Car" or Abelson talks about metacircular evaluation, the language is load-bearing. Don't fight it.

Scheme's homoiconicity — code as data, data as code — clicks in a way that's hard to replicate. If you're running a course on interpreters or compiler construction, this matters enormously. When students write a Scheme interpreter in Scheme, the eval/apply cycle isn't abstract anymore. They're reading ASTs that look exactly like the programs they've been writing all semester. I've watched this moment land with students who spent weeks confused about parse trees, and suddenly it makes sense because there's no gap between the representation and the thing being represented.

For PL theory courses where students implement toy languages — lambda calculus evaluators, type checkers, small interpreters — Scheme stays out of the way better than Haskell. Haskell's type system is expressive but it has opinions, and those opinions can obscure what you're actually trying to teach. In Scheme, you write a closure as a list, you pattern match on symbols, you build an environment as an association list. The implementation mirrors the theory directly. Haskell's algebraic data types are gorgeous for production interpreters; they're sometimes noise when the goal is understanding the concept, not the craft.

The toolchain argument is underrated. With Racket (the practical Scheme to use in 2024), setup is:

# macOS
brew install --cask racket

# Linux
sudo apt install racket

# First program, typed in DrRacket or any editor
#lang racket
(define (factorial n)
  (if (= n 0)
      1
      (* n (factorial (- n 1)))))

(factorial 5) ; => 120

That's it. No cabal dependency resolution, no GHC version matrix, no Stack vs Cabal debate to have on day one. In a 90-minute intro lecture, you can get students writing real recursive functions rather than debugging environment issues. That's not a minor convenience — it's the difference between a cohort that sees functional programming as powerful and one that associates it with friction before they've written a single meaningful line.

When to Pick Haskell

The type system isn't just a feature in Haskell — it's the point. If you're teaching a PL seminar where you want students to feel the difference between a type that describes structure and a type that encodes proof obligations, Haskell is where that lands concretely. You can't fake your way through GADTs or RankNTypes. The compiler forces precision that a whiteboard proof never demands.

I've watched students implement a simply-typed lambda calculus interpreter in both Haskell and Python. The Haskell version invariably produces less working code in the same time — but the code that exists is almost always structurally correct. The type errors they hit are the lesson. When a student can't pattern match on an Expr without handling Lam, App, and Var, they've just internalized exhaustiveness in a way no lecture achieves.

For thesis work, specifically, Haskell earns its keep at 2am. If your implementation needs to enforce that a substitution never escapes its scope, or that a proof term is well-typed by construction, you encode that in types and the compiler becomes your QA engineer. The alternative — a dynamically typed or weakly typed language — means you're trusting test coverage at exactly the moment you have no time to write tests. I've seen more than one thesis defense derailed by a runtime edge case that a sum type would have made impossible.

Research that touches formal verification, dependent types, or category theory maps onto Haskell's vocabulary almost directly. You won't get dependent types themselves — reach for Agda or Idris for that — but you will get functors, natural transformations, adjunctions, and monads as real executable structures, not just metaphors. When Awodey or Mac Lane shows up on your reading list, being able to write the corresponding Haskell is worth more than any number of commutative diagrams drawn in isolation.

-- A concrete example: encoding "non-empty list" in the type,
-- so you can't call 'head' on something empty at compile time
data NonEmpty a = a :| [a]

safeHead :: NonEmpty a -> a
safeHead (x :| _) = x

-- contrast with the runtime bomb version:
-- head [] -- *** Exception: Prelude.head: empty list

Pick Haskell for students who already know one language reasonably well — Python, Java, even C. Haskell is most valuable as a system that breaks their existing mental model. A student coming from Java who assumes mutation is normal, or from Python who assumes runtime errors are acceptable, needs to spend a semester being uncomfortable. That discomfort is the curriculum. Monads, applicatives, and effect systems are another dimension of this: Haskell isn't just one place where you can study these ideas, it's the reference implementation the papers are written against. When you read a paper about algebraic effects and the examples use Haskell syntax, having already built something with mtl or transformers means you're reading code, not hieroglyphs.

Real Config Snippets I Actually Use

The GHC toolchain is notoriously bad at disk hygiene by default. If you hand a student a fresh VS Code setup without pinning haskell.manageHLS, HLS will silently download its own GHC version — separate from whatever ghcup already installed — and you'll burn through 2GB before the first compile succeeds. Fix it with two settings in .vscode/settings.json at the project root:

{
  "haskell.manageHLS": "GHCup",
  "haskell.toolchain": {
    "ghc": "9.4.8",
    "hls": "2.6.0"
  }
}

Commit that file to every assignment repo. Students on Windows especially will thank you — they tend to have the least disk space and the most confusion about why VS Code is "downloading something" for 20 minutes.

My .ghci file has exactly three lines and I've carried them across machines for years:

-- ~/.ghci
:set prompt "ghci> "
:set +t
:set -Wall

:set +t is the one people miss. After every expression you evaluate, GHCi will print the inferred type beneath the result. This is invaluable when you're teaching students to build intuition about Haskell's type system — they stop having to manually :t everything and start noticing patterns. -Wall in the REPL feels aggressive but it catches incomplete pattern matches immediately, which is the #1 silent bug in student submissions.

For Cabal, I stopped writing project files by hand after discovering --minimal actually produces something readable. Run this inside an empty assignment directory:

cabal init --non-interactive --minimal --lib
# or for executable assignments:
cabal init --non-interactive --minimal --exe

The generated .cabal file is about 15 lines with no noise. I then add a cabal.project with one line — packages: . — so cabal repl works from any subdirectory. Don't skip that file; without it, students in nested folders will get confusing "no targets" errors that have nothing to do with their code.

On the Scheme side, the SICP package gotcha has burned every cohort I've taught. You install it correctly:

raco pkg install sicp

Then a student writes #lang sicp at the top and calls (cons-stream 1 2) expecting it to work because the book uses it in chapter 3. It doesn't. cons-stream is a macro that exists in the full sicp package environment only when you also load the stream primitives explicitly. My fix is a shared prelude file I tell students to (require) manually until they hit the streams chapter, at which point they switch to #lang racket with (require racket/stream) — which is actually better behaved anyway.

For Emacs users running Geiser, the .dir-locals.el that actually works looks like this — the key is setting the dialect explicitly per-project so Geiser doesn't try to autodetect and pick the wrong one:

;;; .dir-locals.el at project root
((scheme-mode
  . ((geiser-active-implementations . (racket))
     (geiser-racket-binary . "/usr/bin/racket")
     (geiser-repl-startup-time . 20000))))  ;; bump timeout, Racket's slow to boot

The geiser-repl-startup-time bump matters on student laptops. Default is 10 seconds and Racket on a cold start with several packages routinely exceeds that, leaving Geiser in a broken half-connected state that requires a full M-x geiser-restart-repl to recover. Setting it to 20000ms eliminates that failure mode entirely.

My Actual Verdict After TAing Both

The thing that changed my mind about sequencing wasn't theory — it was watching students in week three. The ones learning Scheme were already writing tree recursion. The ones learning Haskell were still fighting the type system on homework one. Both groups were smart. The difference was friction at the exact moment when the conceptual load is already high.

Start with Scheme or Racket for the first half of any intro FP course. Full stop. DrRacket's error messages are pedagogically designed — they point at the right thing, they don't assume knowledge of the type lattice. Students can test a function in 30 seconds. The feedback loop is tight enough that the language gets out of the way and the ideas come through. Recursion, higher-order functions, thinking without mutation — those are hard enough without also explaining why the compiler rejected your perfectly reasonable list.

Once students have that foundation — once they've written map from scratch, built a small interpreter, stopped reaching for a loop — then Haskell makes sense. The type system stops feeling like a wall and starts feeling like a collaborator, but only because they already understand what it's checking. I've seen students hit the Haskell segment after 6 weeks of Racket and genuinely appreciate what Maybe is doing. I've seen students hit Haskell on week one and conclude that functional programming is punishment.

If you're running a single-language course with students who already have a year or two of Python or Java, Haskell is worth it. The payoff — understanding type classes, seeing algebraic data types click, writing a parser with Parsec — is real and lasting. These students have enough prior scaffolding that the early pain doesn't break their confidence. But if your course is also someone's first exposure to programming-as-thinking-carefully, you will lose people to ghc error messages before they ever understand why laziness matters.

The research vs. homework split is obvious once you've been on both sides. Haskell for research code: the type system catches design errors before runtime, the refactor story is genuinely better, and cabal or stack will at least reproduce your build somewhere else. Scheme for a metacircular evaluator assignment: you want students staring at the evaluator structure, not at a Data.Map.Strict import error. These aren't close calls. Using Haskell for a homework metacircular eval is like using a band saw to cut birthday cake — technically possible, pedagogically indefensible.

The tooling gap has genuinely narrowed. HLS (Haskell Language Server) with VS Code actually works now in a way it didn't three years ago — jump to definition, type hints on hover, reasonable red squiggles. DrRacket has always been good, specifically because it was built for teaching. The honest difference that remains: Haskell's ecosystem still requires babysitting. You will hit a cabal dependency conflict on a fresh machine. You will spend 20 minutes on GHC version mismatch at least once per semester. With Racket, the worst case is usually raco pkg install and you're done. For a 15-week course where setup time is stolen from learning time, that gap still matters.


Disclaimer: This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.


Eric Woo

Written by Eric Woo

Lead AI Engineer & SaaS Strategist

Eric is a seasoned software architect specializing in LLM orchestration and autonomous agent systems. With over 15 years in Silicon Valley, he now focuses on scaling AI-first applications.

Leave a Comment