Polyglot
Languages Haskell scope
Haskell § scope

Modules

Haskell organises code into modules, each contained in a single source file. A module exports a set of names — values, types, type classes, and constructors — that other modules may import. Names not in the export list are private to the module. The mechanism is structurally similar to a Java module or a C# namespace, with the additional features of qualified imports (import qualified Data.Map as M), selective imports (import Data.List (sort, nub)), and re-exports (a module exposing names from imported modules). Beyond modules, Haskell uses lexical scoping: every binding has a scope determined by where it is introduced, with let, where, function arguments, and do-bindings as the principal local-scope mechanisms.

Module declaration

Every Haskell source file may begin with a module header:

module Data.Geometry
    ( Point(..)
    , Shape(..)
    , distance
    , area
    ) where

import Data.List (foldl')
import qualified Data.Map.Strict as Map

-- ... declarations

The principal elements:

  • module Data.Geometry — the module’s name. Conventionally matches the file path: Data.Geometry lives in Data/Geometry.hs.
  • (...) — the export list. Names listed here are exported; names not listed are private.
  • where — separates the header from the declarations.

If the export list is omitted, all top-level names are exported. The conventional discipline in modern Haskell is to write an explicit export list.

The module name is hierarchical: dots in the name correspond to directories. The standard library uses Data.List, Data.Map, Control.Monad, System.IO, and so on; the conventions admit a tree-like organisation.

The export list

The export list controls which names are visible to importers:

module Data.Geometry
    ( -- * Types
      Point
    , Shape(..)         -- export Shape and all its constructors
    , Triangle(Triangle, sides)    -- selective export of constructors
      -- * Functions
    , distance
    , area
    , translate
      -- * Re-exports
    , module Data.Number
    ) where

The notation:

  • Point — exports the type Point only (constructors are private; users cannot construct Point values directly).
  • Shape(..) — exports Shape and all its constructors.
  • Triangle(Triangle, sides) — exports Triangle and selected constructors/fields.
  • module Data.Number — re-exports all names from the imported Data.Number module.

Comments may use Haddock section markers (-- * Types) to group exports for documentation generation.

The abstract data type pattern uses the export list to hide constructors:

module Data.Counter
    ( Counter
    , newCounter
    , increment
    , value
    ) where

data Counter = Counter Int     -- constructor not exported

newCounter :: Counter
newCounter = Counter 0

increment :: Counter -> Counter
increment (Counter n) = Counter (n + 1)

value :: Counter -> Int
value (Counter n) = n

Users cannot construct a Counter directly with Counter 5 — only through newCounter, which guarantees the invariant. The mechanism is the conventional Haskell encapsulation pattern.

Imports

The import directive brings names from another module into scope:

import Data.List                          -- import everything
import Data.List (sort, nub)              -- selective: only sort and nub
import Data.List hiding (sort)            -- everything except sort
import qualified Data.Map as Map          -- qualified: must use Map.foo
import qualified Data.Map.Strict as Map   -- the strict variant
import Data.Map (Map)                     -- import the Map type unqualified
                                           -- but the operations qualified

The forms:

FormEffect
import MBring all of M’s exports into scope unqualified
import M (a, b)Bring only a and b into scope
import M hiding (a)Bring everything except a
import qualified M as NBring all of M’s exports into scope as N.x
import qualified MBring as M.x (no alias)
import qualified M as N (a, b)Selective qualified import

The conventional discipline:

  • Use selective imports (import M (a, b)) for clarity.
  • Use qualified imports for modules whose names would clash with others (Data.Map, Data.Set).
  • Mix qualified imports with type-only unqualified imports: import qualified Data.Map as M plus import Data.Map (Map) admits Map k v as a type and M.lookup k m for operations.

Multiple imports of the same module accumulate; importing Data.List (sort) and then Data.List (nub) makes both available.

Implicit imports: the Prelude

The Prelude module is implicitly imported into every Haskell file:

-- These are all available without explicit import:
length, map, filter, foldr, head, tail, take, drop
print, putStrLn, getLine
(+), (-), (*), (/), (++), (.), ($), (==), (<), (>)
True, False, Maybe, Just, Nothing, Either, Left, Right
IO, return, (>>=), (>>)
Functor, Applicative, Monad, Show, Eq, Ord, Num

The Prelude carries the conventional foundational vocabulary. To suppress the implicit import:

import Prelude ()
import qualified Prelude as P

The NoImplicitPrelude language extension produces the same effect for the file. The mechanism is conventional in code that wants to use a custom prelude (e.g., RIO, relude, protolude) instead of the standard one.

The four scopes

Haskell has four principal scopes:

Module scope

Top-level declarations are in module scope:

module Foo where

constant :: Int
constant = 42                  -- module scope

helper :: Int -> Int
helper n = n + constant        -- can see constant

main :: IO ()
main = print (helper 10)        -- can see helper and constant

Module-scope bindings are visible throughout the module. The export list controls which are visible to importers.

Function (parameter) scope

Function parameters are in scope within the function body:

greet :: String -> String
greet name = "Hello, " ++ name        -- name is in scope here

Parameters shadow same-named module-scope bindings within the function.

let scope

A let binding introduces names visible in the in expression (or in the enclosing do-block):

average :: [Double] -> Double
average xs =
    let total = sum xs
        count = length xs
    in  total / fromIntegral count

The total and count bindings are in scope only within the in expression. let bindings can refer to each other (the bindings are mutually recursive).

where scope

A where clause attaches to a top-level definition or guard, with bindings visible throughout the right-hand side:

quadratic :: Double -> Double -> Double -> Double -> Double
quadratic a b c x = result
  where
    result = a * x * x + b * x + c

classify :: Int -> String
classify n
    | n > 0     = positive
    | n < 0     = negative
    | otherwise = zero
  where
    positive = "positive"
    negative = "negative"
    zero     = "zero"

where bindings can refer to the function’s parameters and to each other. The conventional choice between let and where:

  • where for definitions that conceptually belong with the function but are too detailed to inline.
  • let for bindings local to a single expression.

The choice is largely stylistic; some codebases use where more, others use let.

Pattern bindings in do-notation

Within a do-block, bindings can be introduced with <- (for monadic actions) or let (for pure expressions):

main :: IO ()
main = do
    line <- getLine                -- bind the result of getLine
    let upper = map toUpper line   -- pure binding
        len   = length upper
    putStrLn upper
    print len

The <- binds the result of an IO action; the let introduces pure bindings. Both are scoped to the rest of the do-block.

Lambda parameter scope

A lambda introduces bindings visible in its body:

addOne :: Int -> Int
addOne = \x -> x + 1            -- x is in scope in `x + 1`

uncurry :: (a -> b -> c) -> (a, b) -> c
uncurry f = \(a, b) -> f a b    -- a and b are in scope

Lambdas are first-class values; they may capture variables from the enclosing scope.

Shadowing

Inner bindings shadow outer bindings of the same name:

x :: Int
x = 1

f :: Int -> Int
f x = x + 1                     -- the parameter x shadows the module-level x

main :: IO ()
main = do
    print (f 10)                 -- prints 11
    let x = 100                  -- shadows module-level x within this block
    print x                      -- prints 100

The compiler often warns about shadowing (-Wname-shadowing); the conventional discipline is to avoid shadowing where possible by choosing distinct names.

Type-variable scope

Type variables in a signature are scoped to the signature:

f :: a -> a                     -- a is in scope only in the signature
f x = x

g :: a -> a                     -- a here is NOT the same as a in f's signature
g x = x

The ScopedTypeVariables extension admits using the type variable in the body:

{-# LANGUAGE ScopedTypeVariables #-}

f :: forall a. a -> a
f x = (x :: a)                  -- a is in scope here

The extension is widely used in modern code; without it, type variables in signatures cannot be referenced in the function body.

Common patterns

Selective imports for clarity

import Data.List (sort, nub, group, foldl')
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map

The pattern admits using Map k v as a type and Map.lookup, Map.insert for operations, while also importing specific list functions unqualified.

Hidden constructors for abstract types

module Data.Counter
    ( Counter
    , newCounter
    , increment
    , value
    ) where

data Counter = Counter !Int    -- constructor private

-- ... (constructors are not exported; only the operations)

Users construct Counter values only through the exposed functions.

Re-exporting from a façade module

module Data.Foo
    ( module Data.Foo.Internal
    , module Data.Foo.Util
    , Specific(..)
    ) where

import Data.Foo.Internal
import Data.Foo.Util
import qualified Data.Foo.Specific as Specific

The pattern admits a single import for users while the internal organisation uses several modules.

Internal modules

Many libraries expose an Internal namespace for modules that are technically public but not part of the stable API:

Data.Map                    -- the public API
Data.Map.Internal            -- internals; subject to breakage between versions

Users importing the Internal module accept that it may change without notice.

A note on circular imports

Haskell admits module A importing module B and module B importing module A, but only with care. The conventional disciplines:

  • Use hs-boot files (.hs-boot) to declare the interface of a module without its implementation, breaking the circularity.
  • Restructure the modules to remove the cycle (typically by extracting shared types into a third module).

The conventional advice is to avoid circular imports where possible; when they are unavoidable, the .hs-boot mechanism is the standard solution.

Common patterns

qualified for clarity

import qualified Data.Map.Strict as Map
import qualified Data.Set as Set
import qualified Data.Text as T

main = do
    let m = Map.fromList [("a", 1), ("b", 2)]
    let s = Set.fromList [1, 2, 3]
    let t = T.pack "hello"
    ...

The qualified form makes the source of each operation explicit; the cost is the per-call qualification.

Custom prelude

{-# LANGUAGE NoImplicitPrelude #-}

import RIO                          -- a custom prelude

The RIO and relude packages provide alternative preludes that exclude partial functions, prefer Text over String, and otherwise enforce a more rigorous coding style than the standard Prelude.

Pattern-matching imports

The PatternSynonyms extension admits importing pattern synonyms — abstract patterns that match against constructors without exposing them:

module Data.Foo
    ( Foo
    , pattern Empty
    , pattern Singleton
    ) where

pattern Empty :: Foo
pattern Empty <- (isEmpty -> True)

pattern Singleton :: a -> Foo a
pattern Singleton x <- (toSingleton -> Just x)

The mechanism is rare in routine code but useful for libraries that want to admit pattern-matching against an abstract type.