← Back to DevBytes

Functional Programming in Haskell

Functional Programming in Haskell: A Developer's Guide

Haskell is a purely functional programming language that offers a radically different approach to writing software. Unlike imperative languages where you tell the computer how to do something step by step, Haskell allows you to declare what you want to achieve. This tutorial will walk you through the core concepts, practical usage patterns, and best practices of functional programming in Haskell, providing you with a solid foundation to start building real-world applications.

What is Functional Programming?

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Functional programming is a paradigm centered around the evaluation of mathematical functions and the avoidance of changing state and mutable data. In Haskell, this philosophy is enforced at the language level. Key characteristics include:

Why Functional Programming Matters

Adopting functional programming, and Haskell in particular, brings significant practical benefits to your development workflow:

Core Concepts in Haskell

Pure Functions

A pure function depends solely on its inputs and produces no side effects. This means no mutation of external state, no I/O, and no exceptions leaking out. Here's a simple example:

-- A pure function: given the same radius, always returns the same area
circleArea :: Double -> Double
circleArea r = pi * r ^ 2

-- Another pure function that computes factorial
factorial :: Integer -> Integer
factorial n
  | n <= 1    = 1
  | otherwise = n * factorial (n - 1)

Notice how both functions are self-contained. You can call circleArea 5 a million times and get exactly the same result every time. This predictability is a cornerstone of functional programming.

Immutability

In Haskell, all data is immutable by default. When you "modify" a data structure, you actually create a new copy with the changes applied, while the original remains intact. This is backed by efficient persistent data structures under the hood:

-- Original list
originalList :: [Int]
originalList = [1, 2, 3]

-- "Adding" an element creates a new list
newList :: [Int]
newList = 0 : originalList  -- newList is [0, 1, 2, 3]

-- originalList is still [1, 2, 3] and can be reused safely

Lazy Evaluation

Haskell evaluates expressions lazily — it only computes values when they are actually needed. This enables working with infinite data structures and improves performance by avoiding unnecessary computation:

-- An infinite list of all natural numbers (doesn't blow up memory)
naturals :: [Integer]
naturals = [0..]

-- Take only the first 10 — only those 10 elements are ever computed
firstTen :: [Integer]
firstTen = take 10 naturals  -- [0,1,2,3,4,5,6,7,8,9]

-- Define an infinite Fibonacci sequence lazily
fibonacci :: [Integer]
fibonacci = 0 : 1 : zipWith (+) fibonacci (tail fibonacci)

-- Get the first 20 Fibonacci numbers
first20Fib :: [Integer]
first20Fib = take 20 fibonacci
-- [0,1,1,2,3,5,8,13,21,34,55,89,144,233,377,610,987,1597,2584,4181]

The Type System

Haskell's type system is one of its most powerful features. It's static, strong, and supports type inference, meaning the compiler deduces types for you in most cases while still catching errors at compile time:

-- Type signature: takes a list of any type 'a' and returns an Int
length :: [a] -> Int
length [] = 0
length (_:xs) = 1 + length xs

-- The compiler infers types automatically
-- But explicit signatures are considered best practice for top-level functions
add :: Int -> Int -> Int
add x y = x + y

-- Type variables allow generic programming
identity :: a -> a
identity x = x

Pattern Matching

Pattern matching allows you to destructure data and define function behavior based on the shape of the input. It's more expressive and safer than if-else chains:

-- Pattern matching on a list
sumList :: [Int] -> Int
sumList [] = 0                          -- empty list pattern
sumList (x:xs) = x + sumList xs         -- head and tail pattern

-- Pattern matching on a custom data type
data TrafficLight = Red | Yellow | Green

action :: TrafficLight -> String
action Red    = "Stop"
action Yellow = "Slow down"
action Green  = "Go"

-- Pattern matching with guards
describeNumber :: Int -> String
describeNumber n
  | n < 0     = "Negative"
  | n == 0    = "Zero"
  | n > 0     = "Positive"
  | otherwise = "Unexpected"  -- exhaustive catch-all

Higher-Order Functions

Functions that take other functions as arguments or return functions as results are called higher-order functions. They are fundamental to composing behavior in Haskell:

-- A function that applies another function twice
applyTwice :: (a -> a) -> a -> a
applyTwice f x = f (f x)

-- Usage
result :: Int
result = applyTwice (+3) 10  -- (+3) (+3) 10 = 16

-- Returning a function (currying)
makeAdder :: Int -> Int -> Int
makeAdder x y = x + y

-- Partially apply to create a new function
addFive :: Int -> Int
addFive = makeAdder 5

-- addFive 10 gives 15

Getting Started with Haskell

To begin coding in Haskell, install GHC (the Glasgow Haskell Compiler) and a build tool. The recommended approach is using GHCup:

# On Linux/macOS
curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh

# This installs GHC, cabal-install, and optionally Stack

Once installed, you can start a REPL session with ghci or create a new project:

# Create a new project with Cabal
cabal init

# Or use Stack
stack new my-project

Here is a minimal Haskell source file to get started:

-- Main.hs
module Main where

main :: IO ()
main = putStrLn "Hello, functional world!"

How to Use Functional Programming in Haskell

Working with Lists

Lists are the most common data structure in Haskell. The language provides rich built-in syntax and functions for list processing:

-- List construction
empty :: [Int]
empty = []

smallList :: [Int]
smallList = [1, 2, 3, 4, 5]

-- Cons operator (:)
prepend :: [Int]
prepend = 0 : smallList  -- [0, 1, 2, 3, 4, 5]

-- List concatenation
combined :: [Int]
combined = [1, 2] ++ [3, 4]  -- [1, 2, 3, 4]

-- List comprehensions
squares :: [Int]
squares = [x * x | x <- [1..10]]

-- Comprehension with a predicate
evenSquares :: [Int]
evenSquares = [x * x | x <- [1..20], even x]

Map, Filter, and Fold

These three higher-order functions form the backbone of list processing. You rarely need explicit recursion when you master these:

-- map: transform every element
doubleAll :: [Int] -> [Int]
doubleAll = map (*2)

-- Example: doubleAll [1, 2, 3] gives [2, 4, 6]

-- filter: keep elements that satisfy a predicate
keepEvens :: [Int] -> [Int]
keepEvens = filter even

-- Example: keepEvens [1..10] gives [2, 4, 6, 8, 10]

-- foldl: reduce a list from the left
sumWithFold :: [Int] -> Int
sumWithFold = foldl (+) 0

-- foldr: reduce a list from the right
productWithFold :: [Int] -> Int
productWithFold = foldr (*) 1

-- Combining map, filter, and fold in a pipeline
processNumbers :: [Int] -> Int
processNumbers xs = foldl (+) 0
                   $ filter even
                   $ map (*3) xs

-- For [1, 2, 3, 4, 5]: map (*3) -> [3,6,9,12,15]
-- filter even -> [6,12]
-- foldl (+) 0 -> 18

Function Composition

Composition allows you to chain functions together elegantly, building complex transformations from simple ones:

-- The composition operator (.) combines functions right-to-left
-- (f . g) x = f (g x)

-- Define small, focused functions
trim :: String -> String
trim = dropWhile (== ' ') . reverse . dropWhile (== ' ') . reverse

-- Or more idiomatically:
trim' :: String -> String
trim' = let f = dropWhile (== ' ')
        in f . reverse . f . reverse

-- A pipeline example
wordCount :: String -> Int
wordCount = length . words . filter (/= '\n')

-- Step by step for "hello world\n":
-- filter (/= '\n') -> "hello world"
-- words -> ["hello", "world"]
-- length -> 2

Recursion and Recursion Schemes

Recursion is the primary looping mechanism in Haskell. Instead of while or for loops, you define functions that call themselves with modified arguments until a base case is reached:

-- Classic recursive factorial
factorial :: Integer -> Integer
factorial 0 = 1
factorial n = n * factorial (n - 1)

-- Tail-recursive factorial (uses an accumulator for efficiency)
factorialTail :: Integer -> Integer -> Integer
factorialTail 0 acc = acc
factorialTail n acc = factorialTail (n - 1) (n * acc)

-- QuickSort implemented recursively
quickSort :: Ord a => [a] -> [a]
quickSort [] = []
quickSort (pivot:rest) =
    let smaller = [x | x <- rest, x <= pivot]
        larger  = [x | x <- rest, x > pivot]
    in quickSort smaller ++ [pivot] ++ quickSort larger

Algebraic Data Types

Haskell lets you define your own data types with sum types (alternatives) and product types (combinations). This is incredibly expressive for domain modeling:

-- A sum type: a value is exactly one of these alternatives
data PaymentMethod
  = Cash
  | Card String          -- card number
  | Crypto String Double -- currency and amount
  deriving (Show)

-- A product type with named fields (record syntax)
data Person = Person
  { personName :: String
  , personAge  :: Int
  , personEmail :: String
  } deriving (Show)

-- A recursive data type: a binary tree
data Tree a
  = Empty
  | Node a (Tree a) (Tree a)
  deriving (Show)

-- Function that operates on the Tree type
treeSize :: Tree a -> Int
treeSize Empty = 0
treeSize (Node _ left right) = 1 + treeSize left + treeSize right

-- Create a sample tree
sampleTree :: Tree Int
sampleTree = Node 5
               (Node 3 Empty Empty)
               (Node 8 Empty Empty)

-- treeSize sampleTree gives 3

Handling Side Effects with Monads and IO

Haskell separates pure code from impure, side-effectful code using the IO type. Monads provide a way to sequence operations while keeping the purity of the language intact:

-- A simple IO program
main :: IO ()
main = do
  putStrLn "What is your name?"
  name <- getLine
  putStrLn ("Hello, " ++ name ++ "!")

-- Reading a file and processing its contents purely
countWordsInFile :: FilePath -> IO Int
countWordsInFile path = do
  contents <- readFile path
  -- The pure computation happens here
  let wordCount = length (words contents)
  return wordCount

-- The Maybe monad for computations that might fail
safeDivide :: Double -> Double -> Maybe Double
safeDivide _ 0 = Nothing
safeDivide x y = Just (x / y)

-- Chaining Maybe computations
computeSomething :: Double -> Double -> Double -> Maybe Double
computeSomething a b c = do
  result1 <- safeDivide a b
  result2 <- safeDivide result1 c
  return (result2 * 100)

-- Using >>= explicitly (equivalent to the do-notation above)
computeSomething' :: Double -> Double -> Double -> Maybe Double
computeSomething' a b c =
  safeDivide a b >>= \result1 ->
  safeDivide result1 c >>= \result2 ->
  Just (result2 * 100)

Common Monads in Practice

Beyond IO, Haskell's standard library provides several useful monads for structuring different kinds of computations:

-- The Either monad for computations with error information
type ErrorMessage = String

safeDivideEither :: Double -> Double -> Either ErrorMessage Double
safeDivideEither _ 0 = Left "Division by zero is not allowed"
safeDivideEither x y = Right (x / y)

-- Chaining Either computations
processCalculation :: Double -> Double -> Double -> Either ErrorMessage Double
processCalculation a b c = do
  step1 <- safeDivideEither a b
  step2 <- safeDivideEither step1 c
  if step2 < 0
    then Left "Result is negative"
    else Right (sqrt step2)

-- The List monad for non-deterministic computations
pairs :: [Int] -> [Int] -> [(Int, Int)]
pairs xs ys = do
  x <- xs
  y <- ys
  guard (x + y == 10)
  return (x, y)

-- Usage: pairs [1..10] [1..10]
-- Returns all pairs that sum to 10

Best Practices for Functional Programming in Haskell

1. Write Type Signatures First

Before implementing a function, write its type signature. This clarifies intent, guides implementation, and lets the compiler help you when you veer off course:

-- Start with this
parseConfig :: String -> Either ParseError Config

-- Then implement
parseConfig raw = ...

2. Keep Functions Small and Pure

Aim for functions that do one thing well. If a function exceeds 10–15 lines, consider breaking it into smaller helper functions. Push I/O to the boundaries of your program so the core logic remains pure and testable:

-- Good: pure core logic separated from I/O
validateEmail :: String -> Bool
validateEmail email = '@' `elem` email && not (null email)

main :: IO ()
main = do
  putStrLn "Enter your email:"
  input <- getLine
  if validateEmail input
    then putStrLn "Valid email"
    else putStrLn "Invalid email"

3. Leverage Type-Driven Development

Use the type system to model your domain precisely. Make invalid states unrepresentable by choosing appropriate data types rather than relying on runtime checks:

-- Bad: using strings for everything, validating at runtime
type Email = String  -- any string is allowed, invalid states possible

-- Good: a newtype with a smart constructor
newtype Email = Email String
  deriving (Show)

makeEmail :: String -> Either String Email
makeEmail s
  | '@' `elem` s && not (null s) = Right (Email s)
  | otherwise                    = Left "Invalid email format"

4. Prefer Composition Over Nesting

Use the composition operator (.) and higher-order functions to build pipelines rather than deeply nested function calls:

-- Hard to read
processData :: [Int] -> Int
processData xs = sum (filter even (map (+1) (filter (>0) xs)))

-- Clean and readable with composition
processDataClean :: [Int] -> Int
processDataClean = sum . filter even . map (+1) . filter (>0)

5. Use Algebraic Data Types for Domain Modeling

Sum types let you model choices explicitly. Product types let you group related data. Together they make your domain model precise and self-documenting:

data OrderStatus
  = Pending
  | Confirmed
  | Shipped TrackingNumber
  | Delivered UTCTime
  | Cancelled CancelReason

data CancelReason
  = CustomerRequested
  | OutOfStock
  | PaymentFailed

6. Handle Errors Explicitly with Types

Avoid exceptions for recoverable errors. Use Maybe, Either, or custom error types to make error handling a first-class part of your function signatures:

-- Instead of throwing exceptions
findUser :: UserId -> IO (Either DBError User)
findUser uid = ...

7. Embrace Recursion and Higher-Order Functions Over Loops

Resist the urge to simulate imperative loops. Use recursion for custom iteration patterns and built-in higher-order functions (map, filter, fold) for common list operations:

-- Instead of writing explicit recursion for everything
-- Use fold to capture the essence of the operation
totalRevenue :: [Order] -> Double
totalRevenue = foldr (\order acc -> orderAmount order + acc) 0

8. Use HLint and Compiler Warnings

Enable compiler warnings with -Wall and use hlint to get suggestions for idiomatic code. Haskell's compiler provides incredibly helpful feedback:

# In your .cabal file or on the command line
ghc-options: -Wall

# Run hlint on your source files
hlint src/**/*.hs

9. Test with Property-Based Testing

Use QuickCheck to define properties that should hold for all inputs, rather than testing individual cases manually:

import Test.QuickCheck

-- Property: sorting a list twice should equal sorting it once
prop_sortIdempotent :: [Int] -> Bool
prop_sortIdempotent xs = sort (sort xs) == sort xs

-- Property: the sum of a reversed list equals the sum of the original
prop_sumReverse :: [Int] -> Bool
prop_sumReverse xs = sum (reverse xs) == sum xs

-- Run in ghci: quickCheck prop_sortIdempotent

10. Document with Haddock Comments

Use Haddock syntax to document your modules and functions. This integrates with Haskell's documentation tooling:

-- | Compute the factorial of a non-negative integer.
--
-- >>> factorial 5
-- 120
--
-- The function is not defined for negative inputs.
factorial :: Integer -> Integer
factorial n
  | n < 0     = error "factorial: negative input"
  | n == 0    = 1
  | otherwise = n * factorial (n - 1)

Conclusion

Functional programming in Haskell represents a shift in how you think about software. By embracing purity, immutability, and a powerful type system, you gain the ability to write code that is easier to reason about, test, and refactor. The language's emphasis on composition and higher-order functions encourages building complex systems from small, reliable pieces. While the learning curve can be steep — particularly around monads and lazy evaluation — the investment pays off in code that is concise, correct, and maintainable. Start by writing small pure functions, model your domain with algebraic data types, push side effects to the edges of your program, and let the compiler guide you. With practice, the functional approach becomes not just a tool in your toolkit but a natural way to express solutions clearly and elegantly.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles