Agent Skills: Convert Python to Haskell

Convert Python code to idiomatic Haskell. Use when migrating Python projects to Haskell, translating Python patterns to idiomatic Haskell, or refactoring Python codebases for type safety, pure functional programming, and advanced type system features. Extends meta-convert-dev with Python-to-Haskell specific patterns.

UncategorizedID: arustydev/ai/convert-python-haskell

Repository

aRustyDevLicense: AGPL-3.0
72

Install this agent skill to your local

pnpm dlx add-skill https://github.com/aRustyDev/agents/tree/HEAD/content/skills/convert-python-haskell

Skill Files

Browse the full folder contents for convert-python-haskell.

Download Skill

Loading file tree…

content/skills/convert-python-haskell/SKILL.md

Skill Metadata

Name
convert-python-haskell
Description
Convert Python code to idiomatic Haskell. Use when migrating Python projects to Haskell, translating Python patterns to idiomatic Haskell, or refactoring Python codebases for type safety, pure functional programming, and advanced type system features. Extends meta-convert-dev with Python-to-Haskell specific patterns.

Convert Python to Haskell

Convert Python code to idiomatic Haskell. This skill extends meta-convert-dev with Python-to-Haskell specific type mappings, idiom translations, and tooling for transforming imperative, dynamically-typed Python code into pure functional, statically-typed Haskell with advanced type system features.

This Skill Extends

  • meta-convert-dev - Foundational conversion patterns (APTV workflow, testing strategies)

For general concepts like the Analyze → Plan → Transform → Validate workflow, testing strategies, and common pitfalls, see the meta-skill first.

This Skill Adds

  • Type mappings: Python types → Haskell types (dynamic → static with type inference)
  • Idiom translations: Python patterns → idiomatic Haskell (imperative → pure functional)
  • Module system: Python packages → Haskell modules with explicit exports
  • Error handling: try/except → Maybe/Either monads with do-notation
  • Concurrency: threading/asyncio → async, STM, par monad, forkIO
  • Metaprogramming: decorators → Template Haskell, deriving strategies
  • Zero/Default: None/defaults → Maybe, Default typeclass, smart constructors
  • Serialization: Pydantic → Aeson with FromJSON/ToJSON, Generic deriving
  • Build/Deps: pip/poetry → cabal, stack, hpack
  • Testing: pytest → HSpec, QuickCheck, doctest-haskell
  • Dev Workflow: Python REPL → GHCi with :reload, :type, :kind
  • FFI: C extensions → Haskell FFI, inline-c, hsc2hs

This Skill Does NOT Cover

  • General conversion methodology - see meta-convert-dev
  • Python language fundamentals - see lang-python-dev
  • Haskell language fundamentals - see lang-haskell-dev
  • Reverse conversion (Haskell → Python) - see convert-haskell-python
  • Web frameworks - Django/Flask → Servant/Yesod (see framework-specific guides)

Paradigm Shift Overview

Converting from Python to Haskell requires a fundamental shift in thinking:

| Python Paradigm | Haskell Paradigm | Impact | |-----------------|------------------|--------| | Imperative | Pure functional with effects in IO monad | All side effects explicit | | Dynamic typing | Strong static typing with inference | Errors caught at compile time | | Mutable state | Immutability, State monad, STRef | No accidental mutation | | OOP (classes) | Typeclasses, data types, functions | Data and behavior separate | | Exceptions | Maybe/Either monads | Errors as values | | Duck typing | Polymorphism via typeclasses | Explicit interfaces | | Arbitrary precision ints | Integer (unbounded) or Int (bounded) | Choose precision | | Reference counting GC | Lazy evaluation + GC | Different performance characteristics | | Runtime flexibility | Compile-time guarantees | Less flexibility, more safety |

Key Insight: Haskell forces you to make implicit Python behavior explicit. This initially feels verbose but provides powerful compile-time guarantees.


Quick Reference

| Python | Haskell | Notes | |--------|---------|-------| | int | Int, Integer | Int is bounded, Integer is arbitrary precision | | float | Double, Float | Double preferred | | bool | Bool | Direct mapping | | str | String, Text | String is [Char], Text is efficient | | bytes | ByteString | Data.ByteString | | list[T] | [a] | Linked list | | tuple | (a, b, ...) | Fixed-size tuple | | dict[K, V] | Map k v | Data.Map | | set[T] | Set a | Data.Set | | None | Nothing | From Maybe a | | Optional[T] | Maybe a | Nullable types | | Union[T, U] | Either a b or custom data | Tagged unions | | Callable[[Args], Ret] | (Args) -> Ret | Function types | | async def | IO () or monadic actions | Side effects in IO monad | | @dataclass | data with record syntax | Data types | | Exception | Either e a, ExceptT | Errors as values | | class | data + typeclass instances | Separate data and behavior |

When Converting Code

  1. Analyze source thoroughly - understand Python's implicit behavior
  2. Identify side effects - everything impure goes in IO monad
  3. Map types first - create comprehensive type table
  4. Embrace purity - separate pure logic from effects
  5. Use type inference - let Haskell deduce types where possible
  6. Leverage typeclasses - replace duck typing with explicit constraints
  7. Handle laziness - understand evaluation differences
  8. Test equivalence - QuickCheck for property-based testing

Type System Mapping

Primitive Types

| Python | Haskell | Notes | |--------|---------|-------| | int | Int | Fixed-size (usually 64-bit), can overflow | | int | Integer | Python default - arbitrary precision, no overflow | | float | Double | IEEE 754 double precision (preferred) | | float | Float | Single precision (rarely used) | | bool | Bool | True, False | | str | String | List of Char - inefficient for large text | | str | Text | Preferred - efficient Unicode text from Data.Text | | bytes | ByteString | Efficient byte sequences from Data.ByteString | | None | Nothing | Part of Maybe a type | | ... (Ellipsis) | - | No direct equivalent |

Critical Note on Integers: Python's int has arbitrary precision and never overflows. Haskell's Int is fixed-size (platform-dependent, usually 64-bit) and can overflow. Use Integer for Python-like behavior or validate ranges.

Collection Types

| Python | Haskell | Notes | |--------|---------|-------| | list[T] | [a] | Linked list (prepend O(1), append O(n)) | | list[T] | Seq a | Sequence from Data.Sequence (better performance) | | tuple | (a, b, ...) | Fixed-size, immutable | | dict[K, V] | Map k v | Data.Map - ordered map | | dict[K, V] | HashMap k v | Data.HashMap.Strict - hash-based | | set[T] | Set a | Data.Set - ordered set | | set[T] | HashSet a | Data.HashSet - hash-based | | frozenset[T] | Set a | Immutable by default | | collections.deque | Seq a | Data.Sequence for double-ended queue | | collections.OrderedDict | Map k v | Data.Map maintains insertion order conceptually | | collections.defaultdict | Map k v with findWithDefault | Use smart constructors | | collections.Counter | Map a Int | Count occurrences |

Composite Types

| Python | Haskell | Notes | |--------|---------|-------| | class (data) | data with record syntax | Data containers | | class (behavior) | typeclass | Behavior contracts | | @dataclass | data with deriving (Show, Eq, Generic) | Auto-derive instances | | typing.Protocol | typeclass | Structural → nominal typing | | typing.TypedDict | data with record syntax | Named fields | | typing.NamedTuple | data with positional/record | Prefer record syntax | | enum.Enum | data (sum type) | Algebraic data types | | typing.Literal["a", "b"] | data with constructors | Literal types | | typing.Union[T, U] | Either a b or custom data | Tagged union | | typing.Optional[T] | Maybe a | Nullable types | | typing.Callable[[Args], Ret] | (Args) -> Ret | Function types | | typing.Generic[T] | Polymorphic types | Generic types with type variables |

Type Annotations → Type Signatures

| Python | Haskell | Notes | |--------|---------|-------| | def f(x: T) -> T | f :: a -> a | Polymorphic type variable | | def f(x: Iterable[T]) | f :: [a] -> ... or Foldable t => t a -> ... | Typeclass constraints | | x: Any | Avoid - use type variables | Any defeats type safety | | x: object | Avoid - use polymorphism | No universal base type | | TypeVar('T') | Type variable a, b, etc. | Implicit in Haskell |


Module System Translation

Python Packages → Haskell Modules

Python:

# myproject/utils/helpers.py
def greet(name: str) -> str:
    return f"Hello, {name}!"

def farewell(name: str) -> str:
    return f"Goodbye, {name}!"

# __init__.py exposes API
from .helpers import greet

# Usage in another file
from myproject.utils import greet

Haskell:

-- MyProject/Utils/Helpers.hs
module MyProject.Utils.Helpers
    ( greet      -- Explicitly export greet
    , farewell   -- Explicitly export farewell
    ) where

greet :: String -> String
greet name = "Hello, " ++ name ++ "!"

farewell :: String -> String
farewell name = "Goodbye, " ++ name ++ "!"

-- MyProject/Utils.hs (re-exports selected functions)
module MyProject.Utils
    ( greet
    ) where

import MyProject.Utils.Helpers (greet, farewell)

-- Usage in another module
import MyProject.Utils (greet)

Why this translation:

  • Python has implicit exports (everything is public); Haskell requires explicit export lists
  • Haskell module names match file paths hierarchically
  • No __init__.py equivalent - create a module that re-exports
  • Haskell's import system is more granular (import specific functions, qualified imports)

Import Patterns

| Python | Haskell | Notes | |--------|---------|-------| | import module | import Module | Import everything | | from module import func | import Module (func) | Import specific | | from module import * | import Module | Discouraged in Haskell | | import module as m | import qualified Module as M | Qualified import | | from module import func as f | import Module (func) then alias in code | No direct syntax |

Haskell Import Best Practices:

-- Explicit import list (preferred)
import Data.Map (Map, empty, insert, lookup)

-- Qualified import for disambiguation
import qualified Data.Map as M
import qualified Data.Set as S

-- Import all but hide specific names
import Data.List hiding (head, tail)

-- Import type only (not constructors)
import Data.Map (Map)

Idiom Translation (10 Pillars)

Pillar 1: Module System & Imports

Python:

# myapp/models/user.py
from dataclasses import dataclass
from typing import Optional

@dataclass
class User:
    id: int
    name: str
    email: Optional[str] = None

def find_user(user_id: int, users: list[User]) -> Optional[User]:
    return next((u for u in users if u.id == user_id), None)

Haskell:

-- MyApp/Models/User.hs
module MyApp.Models.User
    ( User(..)    -- Export type and all constructors
    , findUser
    ) where

import Data.Maybe (listToMaybe)

data User = User
    { userId :: Int
    , userName :: String
    , userEmail :: Maybe String
    } deriving (Show, Eq)

findUser :: Int -> [User] -> Maybe User
findUser uid = listToMaybe . filter (\u -> userId u == uid)

Why this translation:

  • Python's @dataclass becomes data with record syntax
  • Explicit exports make API boundaries clear
  • Optional[T] directly maps to Maybe a
  • Generator expression with next() becomes filter + listToMaybe

Pillar 2: Error Handling (try/except → Maybe/Either)

Python:

def parse_age(s: str) -> int:
    """Parse age from string, raising ValueError on invalid input."""
    age = int(s)
    if age < 0:
        raise ValueError("Age must be non-negative")
    return age

def safe_divide(a: float, b: float) -> float:
    if b == 0:
        raise ZeroDivisionError("Cannot divide by zero")
    return a / b

# Usage with try/except
try:
    age = parse_age("25")
    result = safe_divide(10, age)
    print(f"Result: {result}")
except ValueError as e:
    print(f"Value error: {e}")
except ZeroDivisionError as e:
    print(f"Division error: {e}")

Haskell (Maybe):

import Text.Read (readMaybe)

parseAge :: String -> Maybe Int
parseAge s = do
    age <- readMaybe s
    if age >= 0
        then Just age
        else Nothing

safeDivide :: Double -> Double -> Maybe Double
safeDivide _ 0 = Nothing
safeDivide a b = Just (a / b)

-- Usage with do-notation
processAge :: String -> String
processAge input = case parseAge input of
    Nothing -> "Invalid age"
    Just age -> case safeDivide 10 (fromIntegral age) of
        Nothing -> "Cannot divide by zero"
        Just result -> "Result: " ++ show result

-- Or with monadic composition
processAge' :: String -> Maybe Double
processAge' input = do
    age <- parseAge input
    safeDivide 10 (fromIntegral age)

Haskell (Either for detailed errors):

data ParseError = InvalidFormat String | NegativeAge Int
    deriving (Show, Eq)

parseAge :: String -> Either ParseError Int
parseAge s = case readMaybe s of
    Nothing -> Left (InvalidFormat s)
    Just age -> if age >= 0
        then Right age
        else Left (NegativeAge age)

safeDivide :: Double -> Double -> Either String Double
safeDivide _ 0 = Left "Cannot divide by zero"
safeDivide a b = Right (a / b)

-- ExceptT monad transformer for combining error types
import Control.Monad.Except

processAge :: String -> ExceptT String IO ()
processAge input = do
    age <- case parseAge input of
        Left (InvalidFormat s) -> throwError $ "Invalid format: " ++ s
        Left (NegativeAge a) -> throwError $ "Negative age: " ++ show a
        Right a -> return a
    result <- case safeDivide 10 (fromIntegral age) of
        Left err -> throwError err
        Right r -> return r
    liftIO $ putStrLn $ "Result: " ++ show result

Why this translation:

  • Python exceptions become values: Maybe for simple success/failure, Either for detailed errors
  • do-notation provides imperative-style sequencing for monadic operations
  • Pattern matching replaces try/except blocks
  • Error information is preserved in the type system (compile-time checking)

Pillar 3: Concurrency (threading/asyncio → async/STM/par)

Python (threading):

import threading
import time
from queue import Queue

def worker(q: Queue, results: list):
    while True:
        item = q.get()
        if item is None:
            break
        # Simulate work
        time.sleep(0.1)
        results.append(item * 2)
        q.task_done()

# Multi-threaded processing
queue = Queue()
results = []
threads = []

for i in range(4):
    t = threading.Thread(target=worker, args=(queue, results))
    t.start()
    threads.append(t)

for item in range(10):
    queue.put(item)

queue.join()

for _ in range(4):
    queue.put(None)

for t in threads:
    t.join()

print(results)

Haskell (forkIO + TVar):

import Control.Concurrent (forkIO, threadDelay)
import Control.Concurrent.STM
import Control.Monad (replicateM_, forM_)

worker :: TQueue Int -> TVar [Int] -> IO ()
worker queue resultsVar = loop
  where
    loop = do
        maybeItem <- atomically $ do
            empty <- isEmptyTQueue queue
            if empty
                then return Nothing
                else Just <$> readTQueue queue
        case maybeItem of
            Nothing -> return ()  -- Queue empty, exit
            Just item -> do
                threadDelay 100000  -- 0.1 seconds
                atomically $ modifyTVar' resultsVar (++ [item * 2])
                loop

main :: IO ()
main = do
    queue <- newTQueueIO
    resultsVar <- newTVarIO []

    -- Spawn 4 worker threads
    workers <- replicateM 4 $ forkIO (worker queue resultsVar)

    -- Enqueue items
    forM_ [0..9] $ \item ->
        atomically $ writeTQueue queue item

    -- Wait for queue to drain (simplified)
    threadDelay 2000000  -- 2 seconds

    results <- readTVarIO resultsVar
    print results

Python (asyncio):

import asyncio

async def fetch_data(url: str) -> str:
    """Simulate async HTTP request."""
    await asyncio.sleep(0.1)
    return f"Data from {url}"

async def main():
    urls = [f"http://example.com/{i}" for i in range(10)]

    # Concurrent execution
    results = await asyncio.gather(*[fetch_data(url) for url in urls])

    for result in results:
        print(result)

asyncio.run(main())

Haskell (async library):

import Control.Concurrent.Async
import Control.Monad (forM)

fetchData :: String -> IO String
fetchData url = do
    threadDelay 100000  -- 0.1 seconds
    return $ "Data from " ++ url

main :: IO ()
main = do
    let urls = ["http://example.com/" ++ show i | i <- [0..9]]

    -- Concurrent execution with async
    results <- mapConcurrently fetchData urls

    mapM_ putStrLn results

-- Or using async/wait manually
mainManual :: IO ()
mainManual = do
    let urls = ["http://example.com/" ++ show i | i <- [0..9]]

    -- Fork all tasks
    asyncs <- mapM (async . fetchData) urls

    -- Wait for all results
    results <- mapM wait asyncs

    mapM_ putStrLn results

Haskell (STM for shared state):

import Control.Concurrent.STM
import Control.Concurrent (forkIO)
import Control.Monad (replicateM_)

-- Shared counter with STM
incrementCounter :: TVar Int -> Int -> IO ()
incrementCounter counter times = replicateM_ times $ atomically $ do
    current <- readTVar counter
    writeTVar counter (current + 1)

main :: IO ()
main = do
    counter <- newTVarIO 0

    -- 10 threads each incrementing 1000 times
    replicateM_ 10 $ forkIO (incrementCounter counter 1000)

    -- Wait and read final value
    threadDelay 1000000  -- 1 second
    finalValue <- readTVarIO counter
    print finalValue  -- Should be 10000

Why this translation:

  • Python's threading.Thread → Haskell's forkIO (lightweight threads)
  • Python's Queue → Haskell's TQueue (STM-based, composable)
  • Python's asyncio.gather → Haskell's mapConcurrently from async library
  • STM (Software Transactional Memory) provides composable, atomic state changes (superior to locks)
  • Haskell's green threads are cheap (can spawn millions)

Pillar 4: Metaprogramming (decorators → Template Haskell/deriving)

Python (decorators):

from functools import wraps
import time

def timer(func):
    """Decorator to time function execution."""
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} took {end - start:.4f}s")
        return result
    return wrapper

def memoize(func):
    """Decorator for memoization."""
    cache = {}
    @wraps(func)
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return wrapper

@timer
@memoize
def fibonacci(n: int) -> int:
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

# Class decorators
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

Haskell (deriving strategies):

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DerivingStrategies #-}

import GHC.Generics (Generic)
import Data.Aeson (FromJSON, ToJSON)

-- Deriving common typeclasses
data Point = Point
    { x :: Double
    , y :: Double
    } deriving stock (Show, Eq, Generic)
      deriving anyclass (FromJSON, ToJSON)

-- Multiple deriving strategies
newtype UserId = UserId Int
    deriving stock (Show, Eq, Ord)
    deriving newtype (Num, Enum)

Haskell (Template Haskell for code generation):

{-# LANGUAGE TemplateHaskell #-}

import Language.Haskell.TH

-- Generate lenses (getter/setters) for record fields
import Control.Lens (makeLenses)

data User = User
    { _userId :: Int
    , _userName :: String
    , _userEmail :: Maybe String
    } deriving (Show, Eq)

makeLenses ''User  -- Generates lenses: userId, userName, userEmail

Haskell (manual memoization - no decorator syntax):

import Data.Function.Memoize (memoize)

-- Memoized fibonacci
fibMemo :: Int -> Integer
fibMemo = memoize fib
  where
    fib 0 = 0
    fib 1 = 1
    fib n = fibMemo (n - 1) + fibMemo (n - 2)

-- Manual timing wrapper (no decorator syntax)
timed :: IO a -> IO a
timed action = do
    start <- getCurrentTime
    result <- action
    end <- getCurrentTime
    putStrLn $ "Took: " ++ show (diffUTCTime end start)
    return result

import Data.Time.Clock

-- Usage
main :: IO ()
main = timed $ do
    print $ fibMemo 30

Why this translation:

  • Python decorators → Haskell deriving strategies for common patterns
  • @dataclassdata with deriving (Show, Eq, Generic)
  • Template Haskell for compile-time code generation (e.g., lenses, JSON instances)
  • No decorator syntax for functions - use higher-order functions explicitly
  • Memoization via libraries or manual cache management

Pillar 5: Zero/Default Values (None → Maybe, Default typeclass)

Python (None and default arguments):

from typing import Optional

def greet(name: Optional[str] = None) -> str:
    """Greet user with optional name."""
    if name is None:
        name = "Guest"
    return f"Hello, {name}!"

def get_config(key: str, default: int = 0) -> int:
    """Get config value with default."""
    config = {"timeout": 30, "retries": 3}
    return config.get(key, default)

# None as sentinel value
def process_data(data: Optional[list[int]] = None) -> list[int]:
    if data is None:
        data = []
    return [x * 2 for x in data]

Haskell (Maybe):

import Data.Maybe (fromMaybe)

greet :: Maybe String -> String
greet maybeName = "Hello, " ++ name ++ "!"
  where
    name = fromMaybe "Guest" maybeName

-- Or with pattern matching
greet' :: Maybe String -> String
greet' Nothing = "Hello, Guest!"
greet' (Just name) = "Hello, " ++ name ++ "!"

Haskell (Default typeclass):

import Data.Default (Default(..))
import qualified Data.Map as M

data Config = Config
    { timeout :: Int
    , retries :: Int
    , maxSize :: Int
    } deriving (Show)

instance Default Config where
    def = Config
        { timeout = 30
        , retries = 3
        , maxSize = 1024
        }

getConfig :: String -> M.Map String Int -> Int
getConfig key configMap = M.findWithDefault 0 key configMap

-- Usage
main :: IO ()
main = do
    let config = def :: Config
    print config  -- Uses default values

Haskell (smart constructors):

-- Smart constructor with defaults
data User = User
    { userName :: String
    , userAge :: Int
    , userRole :: Role
    } deriving (Show)

data Role = Admin | User | Guest
    deriving (Show)

-- Smart constructor
makeUser :: String -> User
makeUser name = User
    { userName = name
    , userAge = 0       -- Default age
    , userRole = Guest  -- Default role
    }

-- Builder pattern for optional fields
data UserBuilder = UserBuilder
    { builderName :: Maybe String
    , builderAge :: Maybe Int
    , builderRole :: Maybe Role
    }

emptyBuilder :: UserBuilder
emptyBuilder = UserBuilder Nothing Nothing Nothing

withName :: String -> UserBuilder -> UserBuilder
withName n builder = builder { builderName = Just n }

withAge :: Int -> UserBuilder -> UserBuilder
withAge a builder = builder { builderAge = Just a }

build :: UserBuilder -> Maybe User
build (UserBuilder (Just name) maybeAge maybeRole) =
    Just $ User name (fromMaybe 0 maybeAge) (fromMaybe Guest maybeRole)
build _ = Nothing

Why this translation:

  • Python's None → Haskell's Nothing (explicit in type signature)
  • Default arguments → smart constructors or Default typeclass
  • Maybe makes nullable values explicit in type system
  • Builder pattern for complex defaults

Pillar 6: Serialization (Pydantic → Aeson)

Python (Pydantic):

from pydantic import BaseModel, Field, EmailStr, field_validator
from typing import Optional
from datetime import datetime

class User(BaseModel):
    id: int = Field(alias='user_id')
    name: str = Field(min_length=1, max_length=100)
    email: Optional[EmailStr] = None
    age: int = Field(ge=0, le=150)
    created_at: datetime

    @field_validator('name')
    @classmethod
    def name_not_empty(cls, v: str) -> str:
        if not v.strip():
            raise ValueError('name cannot be empty')
        return v

# Usage
import json

user_json = '{"user_id": 1, "name": "Alice", "email": "alice@example.com", "age": 30, "created_at": "2024-01-01T00:00:00"}'
user = User.model_validate_json(user_json)
print(user)

Haskell (Aeson with Generic deriving):

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE OverloadedStrings #-}

import Data.Aeson
import Data.Time (UTCTime)
import GHC.Generics (Generic)
import Data.Text (Text)
import qualified Data.Text as T

data User = User
    { userId :: Int
    , userName :: Text
    , userEmail :: Maybe Text
    , userAge :: Int
    , userCreatedAt :: UTCTime
    } deriving (Show, Generic)

-- Custom Aeson instances with field name mapping
instance FromJSON User where
    parseJSON = withObject "User" $ \v -> User
        <$> v .: "user_id"
        <*> v .: "name"
        <*> v .:? "email"
        <*> v .: "age"
        <*> v .: "created_at"

instance ToJSON User where
    toJSON (User uid name email age created) = object
        [ "user_id" .= uid
        , "name" .= name
        , "email" .= email
        , "age" .= age
        , "created_at" .= created
        ]

-- Validation
validateUser :: User -> Either String User
validateUser user
    | userAge user < 0 || userAge user > 150 =
        Left "Age must be between 0 and 150"
    | T.null (T.strip (userName user)) =
        Left "Name cannot be empty"
    | otherwise =
        Right user

-- Usage
import Data.Aeson (decode, encode)
import qualified Data.ByteString.Lazy as B

parseUser :: B.ByteString -> Either String User
parseUser json = do
    user <- maybe (Left "Invalid JSON") Right (decode json)
    validateUser user

Haskell (Generic deriving for simple cases):

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DeriveAnyClass #-}

import Data.Aeson (FromJSON, ToJSON)
import GHC.Generics (Generic)

-- Automatic JSON instances
data Point = Point
    { x :: Double
    , y :: Double
    } deriving (Show, Generic, FromJSON, ToJSON)

-- Custom field naming strategy
import Data.Aeson.TH (deriveJSON, defaultOptions, fieldLabelModifier)
import Data.Char (toLower)

data Config = Config
    { configTimeout :: Int
    , configRetries :: Int
    } deriving (Show, Generic)

-- Drop "config" prefix and lowercase
$(deriveJSON defaultOptions{fieldLabelModifier = \s -> map toLower (drop 6 s)} ''Config)

Why this translation:

  • Pydantic's BaseModel → Haskell's data + FromJSON/ToJSON instances
  • Field aliases handled in custom JSON instances
  • Validation separate from parsing (parse then validate)
  • Generic deriving reduces boilerplate for simple cases
  • Template Haskell for automatic instance generation with naming strategies

Pillar 7: Build System & Dependencies (pip/poetry → cabal/stack)

Python (pip/poetry):

# pyproject.toml (Poetry)
[tool.poetry]
name = "myproject"
version = "0.1.0"
description = "My Python project"

[tool.poetry.dependencies]
python = "^3.11"
requests = "^2.31.0"
pydantic = "^2.0.0"
aiohttp = "^3.9.0"

[tool.poetry.dev-dependencies]
pytest = "^7.4.0"
mypy = "^1.5.0"
black = "^23.0.0"

# requirements.txt (pip)
requests==2.31.0
pydantic==2.0.0
aiohttp==3.9.0

Haskell (Cabal):

-- myproject.cabal
cabal-version:      3.0
name:               myproject
version:            0.1.0.0
synopsis:           My Haskell project
build-type:         Simple

library
    exposed-modules:  MyProject.Core
                    , MyProject.Utils
    build-depends:    base >=4.16
                    , text >=2.0
                    , aeson >=2.1
                    , http-client >=0.7
                    , http-client-tls >=0.3
    hs-source-dirs:   src
    default-language: GHC2021

executable myproject
    main-is:          Main.hs
    build-depends:    base
                    , myproject
    hs-source-dirs:   app
    default-language: GHC2021

test-suite myproject-test
    type:             exitcode-stdio-1.0
    main-is:          Spec.hs
    build-depends:    base
                    , myproject
                    , hspec >=2.10
                    , QuickCheck >=2.14
    hs-source-dirs:   test
    default-language: GHC2021

Haskell (Stack + package.yaml - simplified):

# package.yaml (hpack format)
name: myproject
version: 0.1.0.0
synopsis: My Haskell project

dependencies:
  - base >= 4.16
  - text >= 2.0
  - aeson >= 2.1
  - http-client >= 0.7
  - http-client-tls >= 0.3

library:
  source-dirs: src
  exposed-modules:
    - MyProject.Core
    - MyProject.Utils

executables:
  myproject:
    main: Main.hs
    source-dirs: app
    dependencies:
      - myproject

tests:
  myproject-test:
    main: Spec.hs
    source-dirs: test
    dependencies:
      - myproject
      - hspec
      - QuickCheck

Comparison:

| Aspect | Python (pip/poetry) | Haskell (cabal/stack) | |--------|---------------------|----------------------| | Package definition | pyproject.toml or setup.py | .cabal file or package.yaml | | Lock file | poetry.lock or requirements.txt | cabal.project.freeze or stack.yaml.lock | | Install deps | poetry install or pip install -r requirements.txt | cabal build or stack build | | Virtual env | poetry shell or venv | Not needed (isolated by default) | | Version constraints | ^2.0.0 (caret), ~=2.0 (tilde) | >=2.0 && <3.0 | | Build tool | poetry build | cabal build or stack build | | Publish | poetry publish | cabal upload |

Why this translation:

  • Cabal is the build system, stack is a build tool wrapping Cabal
  • package.yaml (hpack) generates .cabal files automatically
  • Haskell projects are isolated by default (no need for virtual environments)
  • Cabal supports multiple libraries/executables/test suites in one project
  • Stack uses curated package sets (Stackage) for reproducible builds

Pillar 8: Testing (pytest → HSpec/QuickCheck)

Python (pytest):

import pytest
from myproject.utils import add, divide

def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0
    assert add(0, 0) == 0

def test_divide():
    assert divide(10, 2) == 5
    with pytest.raises(ZeroDivisionError):
        divide(10, 0)

@pytest.mark.parametrize("a,b,expected", [
    (2, 3, 5),
    (-1, 1, 0),
    (0, 0, 0),
])
def test_add_parametrized(a, b, expected):
    assert add(a, b) == expected

# Fixtures
@pytest.fixture
def sample_data():
    return [1, 2, 3, 4, 5]

def test_sum_with_fixture(sample_data):
    assert sum(sample_data) == 15

Haskell (HSpec):

-- test/Spec.hs
import Test.Hspec
import MyProject.Utils (add, safeDivide)

main :: IO ()
main = hspec $ do
    describe "add" $ do
        it "adds two positive numbers" $
            add 2 3 `shouldBe` 5

        it "adds negative and positive" $
            add (-1) 1 `shouldBe` 0

        it "adds zeros" $
            add 0 0 `shouldBe` 0

    describe "safeDivide" $ do
        it "divides two numbers" $
            safeDivide 10 2 `shouldBe` Just 5

        it "returns Nothing for division by zero" $
            safeDivide 10 0 `shouldBe` Nothing

        context "when using parametrized tests" $ do
            let testCases = [(2, 3, 5), (-1, 1, 0), (0, 0, 0)]
            mapM_ (\(a, b, expected) ->
                it ("adds " ++ show a ++ " and " ++ show b) $
                    add a b `shouldBe` expected
                ) testCases

Haskell (QuickCheck - property-based testing):

import Test.QuickCheck

-- Properties for add
prop_add_commutative :: Int -> Int -> Bool
prop_add_commutative x y = add x y == add y x

prop_add_associative :: Int -> Int -> Int -> Bool
prop_add_associative x y z = add (add x y) z == add x (add y z)

prop_add_identity :: Int -> Bool
prop_add_identity x = add x 0 == x

-- Properties for safeDivide
prop_divide_multiply_inverse :: Double -> Double -> Property
prop_divide_multiply_inverse x y = y /= 0 ==> case safeDivide x y of
    Nothing -> False
    Just result -> abs (result * y - x) < 0.0001

-- Running QuickCheck tests
main :: IO ()
main = do
    quickCheck prop_add_commutative
    quickCheck prop_add_associative
    quickCheck prop_add_identity
    quickCheck prop_divide_multiply_inverse

Haskell (HSpec + QuickCheck integration):

import Test.Hspec
import Test.QuickCheck

main :: IO ()
main = hspec $ do
    describe "add properties" $ do
        it "is commutative" $ property $
            \x y -> add x y == add (y :: Int) (x :: Int)

        it "has zero as identity" $ property $
            \x -> add x 0 == (x :: Int)

        it "is associative" $ property $
            \x y z -> add (add x y) z == add x (add (y :: Int) (z :: Int))

Why this translation:

  • pytest assertions → HSpec shouldBe, shouldSatisfy, etc.
  • pytest fixtures → HSpec before hooks or local definitions
  • Parametrized tests → mapM_ over test cases in HSpec
  • QuickCheck adds property-based testing (generates random inputs)
  • Properties express laws (commutativity, associativity, etc.)

Pillar 9: Dev Workflow & REPL (Python REPL → GHCi)

Python REPL:

$ python
>>> from myproject.utils import add, divide
>>> add(2, 3)
5
>>> divide(10, 2)
5.0
>>> # Reload module after changes
>>> import importlib
>>> import myproject.utils
>>> importlib.reload(myproject.utils)
>>> # Introspection
>>> help(add)
>>> type(add)
<class 'function'>
>>> add.__annotations__
{'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}

Haskell GHCi:

$ stack ghci
ghci> :load MyProject.Utils
[1 of 1] Compiling MyProject.Utils
Ok, one module loaded.

ghci> add 2 3
5

ghci> safeDivide 10 2
Just 5.0

-- Type inspection
ghci> :type add
add :: Int -> Int -> Int

ghci> :info add
add :: Int -> Int -> Int
        -- Defined at src/MyProject/Utils.hs:10:1

-- Reload after code changes
ghci> :reload
Ok, one module loaded.

-- Kind inspection (type of types)
ghci> :kind Maybe
Maybe :: * -> *

ghci> :kind Int
Int :: *

-- Browse module exports
ghci> :browse MyProject.Utils
add :: Int -> Int -> Int
safeDivide :: Double -> Double -> Maybe Double

-- Set language extensions
ghci> :set -XOverloadedStrings

-- Multi-line input
ghci> :{
ghci| let factorial 0 = 1
ghci|     factorial n = n * factorial (n - 1)
ghci| :}

ghci> factorial 5
120

-- Debugging
ghci> :break MyProject.Utils.add
Breakpoint 0 activated at src/MyProject/Utils.hs:10:1-15

ghci> :trace add 2 3
Stopped in MyProject.Utils.add, src/MyProject/Utils.hs:10:1-15
_result :: Int = _
[src/MyProject/Utils.hs:10:1-15] ghci> :continue
5

GHCi Commands:

| Command | Purpose | Example | |---------|---------|---------| | :load / :l | Load module | :load Main.hs | | :reload / :r | Reload after changes | :reload | | :type / :t | Show type | :type map | | :kind / :k | Show kind (type of type) | :kind Maybe | | :info / :i | Show definition info | :info Functor | | :browse / :b | List module exports | :browse Data.List | | :set | Set options | :set -XOverloadedStrings | | :quit / :q | Exit GHCi | :quit | | :{ / :} | Multi-line input | :{...} | | :break | Set breakpoint | :break MyModule.myFunc | | :trace | Trace execution | :trace myFunc args |

Why this translation:

  • GHCi is more powerful for type exploration (:type, :kind, :info)
  • :reload is faster than Python's importlib.reload
  • Haskell's static types enable better IDE support (Haskell Language Server)
  • GHCi supports debugging with breakpoints and tracing
  • Multi-line input requires :{ / :} delimiters

Pillar 10: FFI & Interoperability (C extensions → Haskell FFI)

Python (C extension via ctypes):

import ctypes

# Load shared library
libc = ctypes.CDLL("libc.so.6")

# Call C function
libc.printf(b"Hello from C: %d\n", 42)

# Wrapper for type safety
def c_strlen(s: bytes) -> int:
    libc.strlen.argtypes = [ctypes.c_char_p]
    libc.strlen.restype = ctypes.c_size_t
    return libc.strlen(s)

print(c_strlen(b"Hello"))  # 5

Haskell (FFI):

{-# LANGUAGE ForeignFunctionInterface #-}

import Foreign.C.String (CString, withCString, peekCString)
import Foreign.C.Types (CInt(..), CSize(..))

-- Import C function
foreign import ccall "strlen"
    c_strlen :: CString -> IO CSize

-- Wrapper for convenience
strlen :: String -> IO Int
strlen s = withCString s $ \cstr -> do
    len <- c_strlen cstr
    return (fromIntegral len)

main :: IO ()
main = do
    len <- strlen "Hello"
    print len  -- 5

-- Import with unsafe (no callback to Haskell)
foreign import ccall unsafe "strlen"
    c_strlen_unsafe :: CString -> CSize

strlen_pure :: String -> Int
strlen_pure s = fromIntegral $ c_strlen_unsafe (error "null pointer")

Haskell (inline-c for embedding C):

{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TemplateHaskell #-}

import qualified Language.C.Inline as C

C.include "<math.h>"

-- Inline C code
square :: Double -> IO Double
square x = [C.exp| double { pow($(double x), 2) } |]

main :: IO ()
main = do
    result <- square 5.0
    print result  -- 25.0

Haskell (hsc2hs for C headers):

-- File: Time.hsc
{-# LANGUAGE ForeignFunctionInterface #-}

#include <time.h>

import Foreign.C.Types (CTime(..))

type TimeT = CTime

foreign import ccall "time"
    c_time :: Ptr TimeT -> IO TimeT

getCurrentTime :: IO TimeT
getCurrentTime = c_time nullPtr

Comparison:

| Aspect | Python (ctypes/cffi) | Haskell (FFI) | |--------|----------------------|---------------| | Declaration | Runtime (ctypes) | Compile-time (foreign import) | | Type safety | Manual (argtypes, restype) | Automatic (type signature) | | Performance | Moderate overhead | Near-zero overhead | | Inline C | Limited (cffi) | Full support (inline-c, inline-c-cpp) | | Header parsing | Manual | hsc2hs, c2hs tools | | Callback support | Yes (CFUNCTYPE) | Yes (foreign export) |

Why this translation:

  • Haskell FFI is compile-time checked (safer than ctypes)
  • inline-c allows embedding C directly in Haskell code
  • hsc2hs preprocessor extracts constants from C headers
  • foreign export allows calling Haskell from C
  • Performance is better due to compile-time integration

Common Pitfalls

1. Forgetting About Laziness

Problem:

-- Python: Eager evaluation
def process_data(items):
    results = [expensive_func(x) for x in items]
    print(f"Processed {len(results)} items")
    return results

-- Haskell: Lazy evaluation (different behavior!)
processData :: [Int] -> [Int]
processData items = results
  where
    results = map expensiveFunc items  -- NOT evaluated yet!
    -- length results would force evaluation

Solution:

import Control.DeepSeq (force)

-- Force strict evaluation when needed
processData :: [Int] -> [Int]
processData items = force results
  where
    results = map expensiveFunc items

-- Or use strict versions
import qualified Data.Map.Strict as M

2. Confusion Between String and Text

Problem:

-- String is [Char] - inefficient!
slowConcat :: String -> String -> String
slowConcat s1 s2 = s1 ++ s2  -- O(n) for each ++

-- Text is efficient
import Data.Text (Text)
import qualified Data.Text as T

fastConcat :: Text -> Text -> Text
fastConcat t1 t2 = t1 <> t2  -- Efficient

Solution:

{-# LANGUAGE OverloadedStrings #-}

import Data.Text (Text)

-- Use Text for production code
processText :: Text -> Text
processText input = T.toUpper input

3. Not Using Explicit Type Signatures

Problem:

-- Inferred type might be too general
add x y = x + y  -- Inferred: Num a => a -> a -> a

-- Might cause confusing errors later
result = add 1.5 (add 2 3)  -- Error: ambiguous type

Solution:

-- Always add type signatures for top-level functions
add :: Int -> Int -> Int
add x y = x + y

addDouble :: Double -> Double -> Double
addDouble x y = x + y

4. Ignoring Functor/Applicative/Monad

Problem:

-- Imperative style with explicit pattern matching (verbose)
getUserName :: Maybe User -> Maybe String
getUserName maybeUser = case maybeUser of
    Nothing -> Nothing
    Just user -> Just (userName user)

Solution:

-- Use Functor (fmap / <$>)
getUserName :: Maybe User -> Maybe String
getUserName maybeUser = userName <$> maybeUser

-- Or even simpler with point-free style
getUserName :: Maybe User -> Maybe String
getUserName = fmap userName

5. Misunderstanding IO Monad

Problem:

-- Trying to "escape" the IO monad
badGetLine :: String
badGetLine = getLine  -- ERROR: getLine :: IO String, not String

Solution:

-- IO is contagious - functions using IO return IO
goodGetLine :: IO String
goodGetLine = getLine

processInput :: IO ()
processInput = do
    line <- getLine  -- Extract value inside IO context
    putStrLn $ "You said: " ++ line

6. Partial Functions

Problem:

-- Partial functions can crash at runtime
headUnsafe :: [a] -> a
headUnsafe xs = head xs  -- Crashes on empty list!

result = headUnsafe []  -- Runtime error!

Solution:

-- Use total functions (return Maybe)
headSafe :: [a] -> Maybe a
headSafe [] = Nothing
headSafe (x:_) = Just x

-- Or use Data.List.NonEmpty for non-empty lists
import qualified Data.List.NonEmpty as NE

headNonEmpty :: NE.NonEmpty a -> a
headNonEmpty = NE.head  -- Type system guarantees non-empty

7. Integer Overflow

Problem:

-- Python: int has arbitrary precision
# x = 10 ** 100  # Works fine

-- Haskell: Int is bounded
badCompute :: Int
badCompute = 10 ^ 100  -- OVERFLOW! (wraps or crashes)

Solution:

-- Use Integer for arbitrary precision
goodCompute :: Integer
goodCompute = 10 ^ 100  -- Works correctly

-- Or explicitly handle overflow
import Data.Int (Int64)
import GHC.Num.Integer (integerToInt)

8. Space Leaks from Lazy Evaluation

Problem:

-- Lazy fold can build up large thunks
sumLazy :: [Integer] -> Integer
sumLazy = foldl (+) 0  -- Space leak! Builds up (+) thunks

Solution:

import Data.List (foldl')

-- Use strict fold
sumStrict :: [Integer] -> Integer
sumStrict = foldl' (+) 0  -- Evaluates eagerly, no leak

Tooling

Code Translation Tools

| Tool | Purpose | Notes | |------|---------|-------| | Manual translation | Full control | Recommended for production | | No automatic Python→Haskell transpiler | - | Paradigm shift too large |

Development Tools

| Python | Haskell | Purpose | |--------|---------|---------| | python | ghci | REPL | | mypy | ghc (built-in) | Type checking | | pylint / flake8 | hlint | Linting | | black | fourmolu / ormolu | Code formatting | | isort | stylish-haskell | Import sorting | | pdb | GHCi debugger | Debugging | | venv | Not needed | Isolation built-in |

Build Tools

| Python | Haskell | Purpose | |--------|---------|---------| | pip | cabal | Package manager | | poetry | stack | Build tool + package manager | | setuptools | cabal | Build configuration | | wheel | - | Package format (not needed) |

Testing Frameworks

| Python | Haskell | Purpose | |--------|---------|---------| | pytest | hspec | Unit testing | | hypothesis | quickcheck | Property-based testing | | unittest.mock | hspec-mock / HMock | Mocking | | pytest-benchmark | criterion | Benchmarking | | coverage.py | hpc | Code coverage |

Common Library Equivalents

| Python | Haskell | Purpose | |--------|---------|---------| | requests | http-client / http-conduit | HTTP client | | aiohttp | http-client (async via IO) | Async HTTP | | flask / django | servant / yesod / scotty | Web frameworks | | pydantic | aeson + validation | JSON + validation | | click / argparse | optparse-applicative | CLI parsing | | logging | monad-logger / katip | Logging | | datetime | time | Date/time handling | | pathlib | filepath | Path manipulation | | re | regex | Regular expressions | | sqlite3 | sqlite-simple | SQLite | | sqlalchemy | persistent / beam | ORM | | asyncio | async / stm | Concurrency |


Examples

Example 1: Simple - List Processing

Before (Python):

def process_numbers(numbers: list[int]) -> list[int]:
    """Filter even numbers, square them, and sum."""
    evens = [x for x in numbers if x % 2 == 0]
    squared = [x * x for x in evens]
    return sum(squared)

result = process_numbers([1, 2, 3, 4, 5, 6])
print(result)  # 56 (4 + 16 + 36)

After (Haskell):

processNumbers :: [Int] -> Int
processNumbers numbers =
    sum $ map square $ filter even numbers
  where
    square x = x * x

-- Or with function composition
processNumbers' :: [Int] -> Int
processNumbers' = sum . map (^2) . filter even

-- Or with list comprehension (less idiomatic)
processNumbers'' :: [Int] -> Int
processNumbers'' numbers = sum [x^2 | x <- numbers, even x]

main :: IO ()
main = print $ processNumbers [1, 2, 3, 4, 5, 6]  -- 56

Key changes:

  • List comprehension → filter + map (more functional)
  • Function composition with . preferred
  • Type signature required
  • sum works directly on lists

Example 2: Medium - JSON API Client

Before (Python):

import requests
from typing import Optional
from pydantic import BaseModel

class User(BaseModel):
    id: int
    name: str
    email: str

def fetch_user(user_id: int) -> Optional[User]:
    """Fetch user from API."""
    try:
        response = requests.get(f"https://api.example.com/users/{user_id}")
        response.raise_for_status()
        return User(**response.json())
    except requests.HTTPError as e:
        print(f"HTTP error: {e}")
        return None
    except Exception as e:
        print(f"Error: {e}")
        return None

# Usage
if user := fetch_user(123):
    print(f"User: {user.name}")
else:
    print("User not found")

After (Haskell):

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DeriveGeneric #-}

import Network.HTTP.Simple
import Data.Aeson (FromJSON, decode)
import GHC.Generics (Generic)
import qualified Data.ByteString.Lazy as BL

data User = User
    { userId :: Int
    , userName :: String
    , userEmail :: String
    } deriving (Show, Generic, FromJSON)

fetchUser :: Int -> IO (Maybe User)
fetchUser userId = do
    let request = setRequestMethod "GET" $
                  setRequestHost "api.example.com" $
                  setRequestPath (fromString $ "/users/" ++ show userId) $
                  setRequestSecure True $
                  setRequestPort 443 $
                  defaultRequest

    response <- httpLBS request
    let body = getResponseBody response
    return $ decode body

-- Or with http-client-tls and aeson
import Network.HTTP.Client
import Network.HTTP.Client.TLS (newTlsManager)

fetchUser' :: Int -> IO (Either String User)
fetchUser' uid = do
    manager <- newTlsManager
    request <- parseRequest $ "https://api.example.com/users/" ++ show uid
    response <- httpLbs request manager
    case decode (responseBody response) of
        Nothing -> return $ Left "Failed to parse JSON"
        Just user -> return $ Right user

main :: IO ()
main = do
    maybeUser <- fetchUser 123
    case maybeUser of
        Nothing -> putStrLn "User not found"
        Just user -> putStrLn $ "User: " ++ userName user

Key changes:

  • Pydantic → Aeson with FromJSON deriving
  • requestshttp-simple or http-client
  • Exceptions → Maybe or Either for error handling
  • JSON parsing is type-safe at compile time
  • HTTP client requires explicit configuration

Example 3: Complex - Concurrent Web Scraper

Before (Python):

import asyncio
import aiohttp
from typing import List
from dataclasses import dataclass

@dataclass
class Article:
    title: str
    url: str

async def fetch_page(session: aiohttp.ClientSession, url: str) -> str:
    async with session.get(url) as response:
        return await response.text()

async def scrape_articles(urls: List[str]) -> List[Article]:
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_page(session, url) for url in urls]
        pages = await asyncio.gather(*tasks)

        articles = []
        for url, page in zip(urls, pages):
            # Simplified parsing
            title = page.split('<title>')[1].split('</title>')[0]
            articles.append(Article(title=title, url=url))

        return articles

# Usage
urls = [f"https://example.com/page{i}" for i in range(10)]
articles = asyncio.run(scrape_articles(urls))
for article in articles:
    print(f"{article.title}: {article.url}")

After (Haskell):

{-# LANGUAGE OverloadedStrings #-}

import Network.HTTP.Simple
import Control.Concurrent.Async (mapConcurrently)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Text.HTML.TagSoup (parseTags, Tag(..))

data Article = Article
    { articleTitle :: Text
    , articleUrl :: Text
    } deriving (Show)

fetchPage :: String -> IO Text
fetchPage url = do
    request <- parseRequest url
    response <- httpBS request
    return $ TE.decodeUtf8 (getResponseBody response)

parseTitle :: Text -> Text
parseTitle html =
    case dropWhile (not . isTitle) tags of
        (TagOpen "title" _:TagText title:_) -> title
        _ -> "No title"
  where
    tags = parseTags html
    isTitle (TagOpen "title" _) = True
    isTitle _ = False

scrapeArticle :: String -> IO Article
scrapeArticle url = do
    page <- fetchPage url
    let title = parseTitle page
    return $ Article title (T.pack url)

scrapeArticles :: [String] -> IO [Article]
scrapeArticles urls = mapConcurrently scrapeArticle urls

main :: IO ()
main = do
    let urls = ["https://example.com/page" ++ show i | i <- [1..10]]
    articles <- scrapeArticles urls
    mapM_ (\article -> putStrLn $ T.unpack (articleTitle article) ++ ": " ++ T.unpack (articleUrl article)) articles

Key changes:

  • asyncio.gathermapConcurrently from async library
  • aiohttp.ClientSessionNetwork.HTTP.Simple (stateless)
  • HTML parsing with tagsoup library
  • Concurrency via lightweight threads (forkIO under the hood)
  • No need for async/await syntax (IO monad handles effects)

See Also

For more patterns and examples, see:

  • meta-convert-dev - Foundational conversion patterns (APTV workflow)
  • lang-python-dev - Python development patterns
  • lang-haskell-dev - Haskell development patterns
  • patterns-serialization-dev - Cross-language serialization patterns
  • patterns-concurrency-dev - Cross-language concurrency patterns
  • patterns-metaprogramming-dev - Cross-language metaprogramming patterns