Modern 2026 Edition

Learn Modern Haskell for Python and Javascript Developers

In 2026, JavaScript/TypeScript is everywhere, and Python drives AI. But as systems grow, developers face a wall: unhandled state mutations, unpredictable side effects, and concurrency nightmares. Enter Haskell.

Haskell gives you absolute confidence. By enforcing pure functions, immutable data, and explicit side effects at the type level, entire classes of bugs vanish. With lightweight green threads and Software Transactional Memory (STM), Haskell turns concurrency from a nightmare into a breeze. Break through the statefulness ceiling.

2. The Rosetta Stone: Types Grid

Unlike Python and Javascript (which are dynamically typed), Haskell is statically and strongly typed with advanced type inference. You rarely have to write types, but when you do, they guarantee correctness. Notice how Maybe and Either handle nulls and errors without exceptions.

Concept Haskell (Statically Typed) Python 3.12+ (Type Hints) JS (ES2026)
Integer x :: Int
x = 42
x: int = 42 const x = 42;
Float piVal :: Double
piVal = 3.14
pi: float = 3.14 const pi = 3.14;
String name :: String
name = "Hi"
name: str = "Hi" const name = "Hi";
List/Array nums :: [Int]
nums = [1, 2, 3]
nums: list[int] = [1, 2, 3] const nums = [1, 2, 3];
Dictionary map :: Map String Int
map = Map.fromList [("k", 1)]
map: dict[str, int] = {"k": 1} const map = new Map();
map.set("k", 1);
Null/None val :: Maybe Int
val = Nothing
val: int | None = None const val = null;
Exceptions res :: Either Error Int
res = Right 5
Exception (Runtime) Error (Runtime)
Day-to-Day Gotcha: Haskell does not have null or undefined. Instead, it uses the Maybe a type (Just value or Nothing). It generally eschews throwing exceptions; it returns an Either e a (Right value or Left error). You must pattern match these explicitly.

3. Guess the Number Game

Haskell Features Introduced: The IO Monad, do notation, pattern matching, and recursion (instead of loops).

Haskell (Main.hs)
import System.Random (randomRIO)
import Text.Read (readMaybe)
import System.IO (hFlush, stdout)

-- In Haskell, side effects (like printing or random numbers) MUST be marked with the IO type.
main :: IO ()
main = do
    -- Generate a random number. `<-` extracts the pure value from the IO action.
    secretNumber <- randomRIO (1, 100)
    putStrLn "Guess the number between 1 and 100!"
    -- We use recursion instead of a `while` loop
    gameLoop secretNumber

-- Functions that take an Int and perform IO
gameLoop :: Int -> IO ()
gameLoop secret = do
    putStr "> "
    hFlush stdout -- Forces output to appear before user types
    guessStr <- getLine
    
    -- readMaybe safely attempts to parse the string into an Int, returning a `Maybe Int`
    case readMaybe guessStr of
        Nothing -> do
            putStrLn "Please type a valid number!"
            gameLoop secret -- Loop back
            
        Just guess -> case compare guess secret of
            LT -> do
                putStrLn "Higher!"
                gameLoop secret
            GT -> do
                putStrLn "Lower!"
                gameLoop secret
            EQ -> putStrLn "You win!"
Python 3.12+
import random

def main():
    secret_number = random.randint(1, 100)
    print("Guess the number between 1 and 100!")
    
    while True:
        try:
            guess = int(input("> ").strip())
        except ValueError:
            print("Please type a valid number!")
            continue
            
        if guess < secret_number: 
            print("Higher!")
        elif guess > secret_number: 
            print("Lower!")
        else:
            print("You win!")
            break

if __name__ == "__main__": 
    main()
Node.js (ES2026)
import * as readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';

async function main() {
    const rl = readline.createInterface({ input, output });
    const secret = Math.floor(Math.random() * 100) + 1;
    console.log("Guess the number between 1 and 100!");

    while (true) {
        const guess = parseInt((await rl.question('> ')).trim(), 10);
        if (isNaN(guess)) {
            console.log("Please type a valid number!");
            continue;
        }

        if (guess < secret) console.log("Higher!");
        else if (guess > secret) console.log("Lower!");
        else {
            console.log("You win!");
            break;
        }
    }
    rl.close();
}
main();

4. Arithmetic Command Line Game

Haskell Features Introduced: Let bindings inside do blocks, pure helper functions.

Haskell
import System.Random (randomRIO)
import Text.Read (readMaybe)
import System.IO (hFlush, stdout)

main :: IO ()
main = do
    putStrLn "Solve the addition problems! Type 'quit' to exit."
    playLoop

playLoop :: IO ()
playLoop = do
    a <- randomRIO (1, 10)
    b <- randomRIO (1, 10)
    
    putStr $ "What is " ++ show a ++ " + " ++ show b ++ "? "
    hFlush stdout
    
    input <- getLine
    if input == "quit"
        then putStrLn "Thanks for playing!"
        else do
            -- `let` is used inside `do` blocks to bind pure values (no IO)
            let correctAnswer = a + b
            case readMaybe input of
                Just ans | ans == correctAnswer -> putStrLn "Correct!"
                         | otherwise            -> putStrLn $ "Wrong! It was " ++ show correctAnswer
                Nothing -> putStrLn "Please enter a number or 'quit'."
            
            playLoop -- Recurse to play the next round
Python 3.12+
import random

def main():
    print("Solve the addition problems! Type 'quit' to exit.")
    
    while True:
        a, b = random.randint(1, 10), random.randint(1, 10)
        user_input = input(f"What is {a} + {b}? ").strip()
        
        if user_input == "quit":
            print("Thanks for playing!")
            break
            
        try:
            if int(user_input) == a + b: 
                print("Correct!")
            else: 
                print(f"Wrong! It was {a + b}.")
        except ValueError:
            print("Please enter a number or 'quit'.")

if __name__ == "__main__": main()
Node.js
import * as readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';

async function main() {
    const rl = readline.createInterface({ input, output });
    console.log("Solve addition! Type 'quit' to exit.");

    while (true) {
        const a = Math.floor(Math.random() * 10) + 1;
        const b = Math.floor(Math.random() * 10) + 1;
        
        const userInput = (await rl.question(`What is ${a} + ${b}? `)).trim();
        if (userInput === 'quit') break;
        
        const answer = parseInt(userInput, 10);
        if (isNaN(answer)) console.log("Enter a number or 'quit'.");
        else if (answer === a + b) console.log("Correct!");
        else console.log(`Wrong! It was ${a + b}.`);
    }
    rl.close();
}
main();

5. State Machine & Settings

Haskell Features Introduced: Algebraic Data Types (ADTs), Record Syntax, and state passing.

Haskell (The power of ADTs)
import System.Random (randomRIO)
import Text.Read (readMaybe)
import System.IO (hFlush, stdout)

-- Algebraic Data Types form the backbone of safe State Machines
data Operation = Add | Multiply deriving (Show, Eq)
data AppState = Menu | Playing | Quit deriving (Show, Eq)

-- Record syntax allows us to name fields in a complex structure
data Settings = Settings {
    minVal :: Int,
    maxVal :: Int,
    op     :: Operation
} deriving (Show)

defaultSettings :: Settings
defaultSettings = Settings { minVal = 1, maxVal = 10, op = Add }

main :: IO ()
main = runMachine Menu defaultSettings

-- We pass the State and Settings explicitly through recursion. No global mutable variables!
runMachine :: AppState -> Settings -> IO ()
runMachine Quit _ = return ()
runMachine Menu settings = do
    putStr "1. Play  2. Set Multiply  3. Quit\n> "
    hFlush stdout
    input <- getLine
    case input of
        "1" -> runMachine Playing settings
        -- We return a NEW settings object with the `op` changed
        "2" -> do
            putStrLn "Operation set to Multiplication."
            runMachine Menu (settings { op = Multiply })
        "3" -> runMachine Quit settings
        _   -> do
            putStrLn "Invalid option."
            runMachine Menu settings

runMachine Playing settings = do
    a <- randomRIO (minVal settings, maxVal settings)
    b <- randomRIO (minVal settings, maxVal settings)
    
    let (symbol, correct) = case op settings of
            Add      -> ("+", a + b)
            Multiply -> ("*", a * b)

    putStr $ "What is " ++ show a ++ " " ++ symbol ++ " " ++ show b ++ "? ('menu' to go back) "
    hFlush stdout
    
    input <- getLine
    if input == "menu"
        then runMachine Menu settings
        else do
            case readMaybe input of
                Just ans | ans == correct -> putStrLn "Correct!"
                         | otherwise      -> putStrLn $ "Wrong, it was " ++ show correct
                Nothing -> putStrLn "Not a number."
            runMachine Playing settings
Python 3.12+ (Dataclasses & Match)
import random
from enum import Enum, auto
from dataclasses import dataclass

class Operation(Enum): ADD = auto(); MULTIPLY = auto()
class AppState(Enum): MENU = auto(); PLAYING = auto(); QUIT = auto()

@dataclass
class Settings:
    min: int = 1
    max: int = 10
    op: Operation = Operation.ADD

def main():
    state, settings = AppState.MENU, Settings()

    while True:
        match state:
            case AppState.MENU:
                user_in = input("1. Play  2. Multiply  3. Quit\n> ").strip()
                match user_in:
                    case "1": state = AppState.PLAYING
                    case "2": settings.op = Operation.MULTIPLY
                    case "3": state = AppState.QUIT
                    case _: print("Invalid option.")
            
            case AppState.PLAYING:
                a = random.randint(settings.min, settings.max)
                b = random.randint(settings.min, settings.max)
                sym, cor = ("+", a+b) if settings.op == Operation.ADD else ("*", a*b)
                    
                ans = input(f"What is {a} {sym} {b}? ('menu' to exit) ").strip()
                if ans == "menu":
                    state = AppState.MENU
                    continue
                    
                try:
                    if int(ans) == cor: print("Correct!")
                    else: print(f"Wrong, it was {cor}")
                except ValueError: pass
            
            case AppState.QUIT: break

if __name__ == "__main__": main()
Node.js
import * as readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';

const AppState = { MENU: 'MENU', PLAYING: 'PLAYING', QUIT: 'QUIT' };
const Operation = { ADD: 'ADD', MULTIPLY: 'MULTIPLY' };

async function main() {
    const rl = readline.createInterface({ input, output });
    let state = AppState.MENU;
    let settings = { min: 1, max: 10, op: Operation.ADD };

    while (true) {
        switch (state) {
            case AppState.MENU:
                const menuIn = (await rl.question("1. Play  2. Set Multiply  3. Quit\n> ")).trim();
                if (menuIn === "1") state = AppState.PLAYING;
                else if (menuIn === "2") settings.op = Operation.MULTIPLY;
                else if (menuIn === "3") state = AppState.QUIT;
                break;
                
            case AppState.PLAYING:
                const a = Math.floor(Math.random() * 10) + 1;
                const b = Math.floor(Math.random() * 10) + 1;
                
                const symbol = settings.op === Operation.ADD ? '+' : '*';
                const correct = settings.op === Operation.ADD ? a + b : a * b;
                
                const ansStr = (await rl.question(`What is ${a} ${symbol} ${b}? `)).trim();
                if (ansStr === 'menu') { state = AppState.MENU; continue; }
                
                const ans = parseInt(ansStr, 10);
                if (!isNaN(ans)) {
                    if (ans === correct) console.log("Correct!");
                    else console.log(`Wrong, it was ${correct}`);
                }
                break;
                
            case AppState.QUIT:
                rl.close();
                return;
        }
    }
}
main();

6. Flashcards Quizzer: Higher Order Functions

Haskell Features Introduced: mapM_, Data structures, and dealing with list iterations functionally.

import System.Random.Shuffle (shuffleM) -- Requires the 'random-shuffle' package
import System.IO (hFlush, stdout)

-- Data types with named records
data Flashcard = Flashcard {
    question :: String,
    answer   :: String
} deriving (Show)

main :: IO ()
main = do
    let deck = [ Flashcard "Capital of France?" "Paris"
               , Flashcard "2 ** 8?" "256"
               , Flashcard "Haskell build tool?" "Cabal" ]

    -- shuffleM shuffles the list within the IO monad
    shuffledDeck <- shuffleM deck

    -- Instead of `for` loops, we use `mapM_` to map an IO action over a list
    mapM_ askCard shuffledDeck
    
    putStrLn "Deck completed!"

askCard :: Flashcard -> IO ()
askCard card = do
    putStr $ "Q: " ++ question card ++ " (Press Enter)"
    hFlush stdout
    _ <- getLine
    putStrLn $ "A: " ++ answer card ++ "\n"

7. OS Downloads Folder Sorter

Haskell Features Introduced: File system interactions (System.Directory, System.FilePath), and mapping pure categorizations to IO.

import System.Directory (listDirectory, doesFileExist, createDirectoryIfMissing, renameFile)
import System.FilePath ((), takeExtension)
import Control.Monad (forM_)
import Data.Char (toLower)

main :: IO ()
main = do
    let downloadsDir = "./downloads_test"
    createDirectoryIfMissing True downloadsDir
    
    -- listDirectory reads the contents of the folder
    entries <- listDirectory downloadsDir
    
    -- forM_ is similar to a for-loop, it executes an IO action for each item
    forM_ entries $ \fileName -> do
        let path = downloadsDir  fileName
        isFile <- doesFileExist path
        
        if isFile
            then do
                -- takeExtension safely pulls out the extension (e.g., ".pdf")
                let ext = drop 1 $ map toLower (takeExtension fileName)
                let folderName = getCategory ext
                let targetDir = downloadsDir  folderName
                
                createDirectoryIfMissing True targetDir
                let newPath = targetDir  fileName
                
                renameFile path newPath
                putStrLn $ "Moved " ++ fileName ++ " to " ++ targetDir
            else return ()

-- A perfectly pure function to categorize files!
getCategory :: String -> String
getCategory ext = case ext of
    "pdf"  -> "Documents"
    "docx" -> "Documents"
    "txt"  -> "Documents"
    "jpg"  -> "Media"
    "png"  -> "Media"
    "mp4"  -> "Media"
    "zip"  -> "Compressed"
    "tar"  -> "Compressed"
    _      -> "Others"

8. The Haskell Superpower: Pure Immutability & Threading

Python and Javascript are fundamentally built on mutable state. This leads to unpredictable side effects and concurrency deadlocks (or forced single-threaded execution like the GIL). Haskell solves this structurally.

1. Pure Immutability

You cannot change variables in Haskell. Instead of loops mutating a counter, you pass state forward recursively. This entirely prevents "Data Races" across threads.

-- Python: 
-- count = 0
-- for i in items: count += 1

-- Haskell: We don't mutate count. We use a fold.
let count = foldl (\acc _ -> acc + 1) 0 items
-- Or better yet, just use the built-in pure function:
let count = length items

2. Concurrency (MVar & STM)

Because 99% of Haskell code is pure (immutable), multi-threading is trivial. When you *do* need shared state, Haskell provides MVar or Software Transactional Memory (STM), which acts like a database transaction for memory.

import Control.Concurrent

main = do
    -- Create an empty MVar (thread-safe mailbox)
    mvar <- newEmptyMVar
    
    -- forkIO spawns an extremely lightweight green thread
    forkIO $ do
        putStrLn "Thread 1 doing work..."
        putMVar mvar "Result Data" -- Puts data in the box
        
    -- The main thread blocks here until the data arrives
    result <- takeMVar mvar
    putStrLn $ "Main thread received: " ++ result

The Haskell Concurrency Model

How data moves between green threads

1
Pure Functions (Default)

Immutable data can be shared among 100,000 threads simultaneously with zero locks. It is completely safe.

AND
IO
STM / MVars for State

Explicitly marked thread-safe containers. If an STM transaction conflicts, the runtime automatically retries it.

"Fearless Concurrency" isn't a buzzword in Haskell, it's a mathematical guarantee.

9. Haskell Specific Tips & Tricks

Haskell has elegant syntax choices that can confuse newcomers. Here is your cheat sheet.

$

The Application Operator $

In Haskell, space means function application (e.g. f x). But sometimes you have lots of parentheses: putStrLn (show (length list)).

The $ operator simply means "evaluate everything on the right first". It lets you drop parentheses!
putStrLn $ show $ length list

.

Function Composition .

Mathematical composition! If you want to chain functions together without mentioning the data explicitly (Point-Free style), use the dot.

Instead of: process x = reverse (sort (map toLower x))
You write: process = reverse . sort . map toLower

lazy

Lazy Evaluation & Space Leaks

Haskell is Lazy by default. It won't compute x = 2 + 2 until you actually print x. This allows infinite lists like [1..]!
The Trap: If you build up a huge chain of math without evaluating it, it consumes memory (a thunk). To force strict evaluation, use the $! operator or strict data fields ({-# LANGUAGE StrictData #-}).

10. Standard Library & Ecosystem Essentials

Haskell's standard library is intentionally kept small (the base package). For day-to-day work, you will rely on battle-tested ecosystem packages from Hackage.

Requires aeson in your cabal file. Aeson is the absolute standard for JSON in Haskell. With GHC Generics, you get zero-boilerplate parsing.

{-# LANGUAGE DeriveGeneric #-}
import Data.Aeson
import GHC.Generics
import qualified Data.ByteString.Lazy.Char8 as B

-- DeriveGeneric enables Aeson to automatically write the parser!
data User = User {
    id        :: Int,
    name      :: String,
    isActive  :: Bool
} deriving (Show, Generic)

instance FromJSON User
instance ToJSON User

main :: IO ()
main = do
    let jsonData = "{\"id\": 1, \"name\": \"Alice\", \"isActive\": true}"
    
    -- decode returns a Maybe User
    let parsed = decode jsonData :: Maybe User
    print parsed

    -- encode converts it back to ByteString
    case parsed of
        Just user -> B.putStrLn $ encode user
        Nothing   -> putStrLn "Failed to parse JSON"

Haskell's default String is actually a linked list of characters ([Char]), which is slow. For real applications, use the Text package and enable OverloadedStrings.

{-# LANGUAGE OverloadedStrings #-}
import qualified Data.Text as T
import qualified Data.Text.IO as TIO

main :: IO ()
main = do
    -- Thanks to OverloadedStrings, this string literal is created as Text
    let myText = "Hello, High Performance Haskell!" :: T.Text
    
    -- Write efficiently to a file
    TIO.writeFile "output.txt" myText
    
    -- Read back
    content <- TIO.readFile "output.txt"
    TIO.putStrLn content

The gold standard for command line interfaces in Haskell. It uses Applicative Functors to compose argument parsers elegantly.

import Options.Applicative
import Data.Semigroup ((<>))

data Args = Args { name :: String, count :: Int }

argsParser :: Parser Args
argsParser = Args
      <$> strOption (long "name" <> short 'n' <> help "Name to greet")
      <*> option auto (long "count" <> short 'c' <> value 1 <> showDefault <> help "Greet count")

main :: IO ()
main = do
    let opts = info (argsParser <**> helper) (fullDesc <> progDesc "A simple greeter")
    args <- execParser opts
    
    -- _ means discard the result, we just want the side-effect (printing)
    mapM_ (\_ -> putStrLn $ "Hello " ++ name args ++ "!") [1 .. count args]

The req package provides an easy-to-use, type-safe HTTP client.

{-# LANGUAGE OverloadedStrings #-}
import Network.HTTP.Req
import qualified Data.ByteString.Char8 as B

main :: IO ()
main = runReq defaultHttpConfig $ do
    -- Types ensure we can't accidentally send a GET payload incorrectly
    let url = https "api.github.com" /: "repos" /: "haskell" /: "haskell"
    
    r <- req GET url NoReqBody bsResponse (header "User-Agent" "haskell-guide")
    
    -- Lift the IO action so it runs inside the Req monad
    liftIO $ B.putStrLn $ responseBody r

11. Software Engineering Best Practices (Tooling)

The Haskell ecosystem has matured incredibly in recent years. You no longer need to fight the setup. Use the modern stack.

GHCup & Cabal

GHCup is the universal installer for Haskell (think rustup or nvm). It installs GHC (the compiler), Cabal (the build tool/package manager), and HLS. Run cabal run and you are off to the races.

Haskell Language Server (HLS)

HLS provides world-class IDE support in VSCode or Neovim. It gives you instant type-hints, error checking, and code lenses (like Evaluate). It transforms Haskell development from "fighting the compiler" to "chatting with an assistant."

Hoogle

A search engine specifically for Haskell. Instead of searching by name, you can search by type signature. Want a function that turns a String into an Int? Search for String -> Int on hoogle.haskell.org.

Ormolu / Fourmolu

The standard code formatters. Like Prettier or Black, they format your Haskell code beautifully and deterministically. Integrate it into HLS to format-on-save.