Polyglot
Languages Haskell strings
Haskell § strings

Strings

Haskell’s String type is, unhelpfully, a list of characters: type String = [Char]. The implication is that strings inherit all the operations of lists — concatenation, mapping, filtering, head and tail — and all the costs: linked-list representation, O(n) indexing, lazy evaluation, and substantial allocation overhead. For most practical text processing, Haskell programmers use the Text type from the text package or ByteString from bytestring; both are packed contiguous representations that admit efficient operations. The OverloadedStrings extension makes string literals polymorphic over the IsString class, admitting the same literal syntax for String, Text, ByteString, and any other text-like type.

This page covers the principal text-handling types and the conventions for choosing among them.

String and the list-of-Char representation

The default String type:

type String = [Char]

s :: String
s = "hello"

A String is a singly-linked list of Char values. Every operation that mutates a String (in the immutable sense — produces a new value) is a list operation:

"hello" ++ ", world"               -- list concatenation
length "hello"                      -- list length: 5
reverse "hello"                     -- "olleh"
map toUpper "hello"                 -- "HELLO"
filter (/= 'l') "hello"             -- "heo"
take 3 "hello"                      -- "hel"
drop 3 "hello"                      -- "lo"
"abc" !! 1                          -- 'b' (indexing; partial)

The principal operations:

  • (++) — concatenation; O(n).
  • length — O(n); requires walking the list.
  • head, tail, last, init — O(1) for head and tail; O(n) for last and init.
  • take, drop, splitAt — O(n).
  • null — O(1); whether the list is empty.

The list representation has substantial overhead: each character is a Char value with two links (the value and the pointer to the next cell), making a String of length n take O(n) in memory just for the cells. The lazy evaluation produces additional allocations for the thunks. For text-heavy work, String is rarely the right choice.

Text from the text package

The Text type is the conventional choice for non-trivial text processing:

import qualified Data.Text as T
import Data.Text (Text)

s :: Text
s = T.pack "hello"

upper :: Text
upper = T.toUpper s

Text is a packed UTF-16 (or, since text-2.0, UTF-8) representation. The principal advantages over String:

  • Constant-time length.
  • O(1) indexing (in the modern UTF-8 representation).
  • Substantially less memory — a single contiguous buffer rather than a linked list.
  • Faster operations — most operations admit cache-friendly access.

The conventional discipline is to use Text for any non-trivial text. The Data.Text module provides:

OperationEffect
T.lengthO(1) length
T.appendConcatenation
T.head, T.last, T.tail, T.initList-style accessors
T.take, T.drop, T.splitAtSlicing
T.toUpper, T.toLowerCase conversion
T.replaceSubstring replacement
T.split, T.splitOn, T.words, T.linesSplitting
T.unwords, T.unlines, T.intercalateJoining
T.reverseReverse
T.find, T.findIndexSearch
T.isPrefixOf, T.isSuffixOf, T.isInfixOfMembership
T.toLazyText, T.toStrictConversions to/from lazy Text

The Data.Text.Lazy variant provides a lazy chunked representation suitable for streaming.

ByteString from the bytestring package

For byte-level work — files, network protocols, binary formats — the ByteString type is the conventional choice:

import qualified Data.ByteString as BS
import Data.ByteString (ByteString)

bs :: ByteString
bs = BS.pack [104, 101, 108, 108, 111]    -- "hello" in ASCII bytes

ByteString is a packed array of bytes (Word8). It is not a text type — the ByteString does not know about character encoding. For ASCII text, ByteString is effectively interchangeable with text; for Unicode text, ByteString represents the encoded bytes (typically UTF-8) and explicit decoding is required.

The principal uses:

  • File I/O — BS.readFile, BS.writeFile.
  • Network protocols — most networking libraries use ByteString for transport.
  • Binary formats — Data.ByteString.Builder for efficient construction.

The conversion between ByteString and Text requires an explicit charset:

import qualified Data.Text.Encoding as TE
import qualified Data.ByteString as BS
import Data.Text (Text)

decodeUtf8 :: BS.ByteString -> Text
decodeUtf8 = TE.decodeUtf8

encodeUtf8 :: Text -> BS.ByteString
encodeUtf8 = TE.encodeUtf8

The Data.Text.Encoding.decodeUtf8 may throw on malformed UTF-8; decodeUtf8' returns Either UnicodeException Text for safe decoding.

String literals

By default, a string literal has type String:

hello :: String
hello = "hello, world"

With the OverloadedStrings language extension, string literals become polymorphic over the IsString type class:

{-# LANGUAGE OverloadedStrings #-}

import Data.Text (Text)
import qualified Data.ByteString as BS

s :: Text
s = "hello"            -- the literal works for any IsString instance

bs :: BS.ByteString
bs = "hello"           -- also works

The OverloadedStrings extension is widely used in modern Haskell code; it is enabled in most projects’ .cabal files or by per-file pragma. Without it, every literal must be wrapped: T.pack "hello", BS.pack "hello".

Escape sequences

Haskell character and string literals admit the conventional escape sequences:

"line1\nline2"           -- with newline
"tab\there"              -- with tab
"backslash: \\"          -- literal backslash
"quote: \""              -- literal double quote
"unicode: \x41"          -- literal hex character (A)
"unicode: A"        -- the same; Haskell admits \u for short hex
"control: \^A"           -- control-A (rare)
"null: \0"               -- null character
"\NUL\STX\ETX"           -- ASCII control names

The full list of escapes is in the language standard; the conventional ones (\n, \t, \r, \\, \", \xHH, \u{HHHH} with extension) cover most uses.

Multiline strings without explicit escaping are handled by the \ continuation:

multiline :: String
multiline = "line one \n\
            \line two \n\
            \line three"

The \ at the end of a line and the \ at the start of the next admit splitting a long literal across lines without inserting a newline.

The MultilineStrings extension (proposed for GHC) is not yet in stable releases; for multi-line strings, the conventional alternatives are explicit \n or text blocks via library functions.

Character operations

The Data.Char module provides character-level operations:

import Data.Char

isDigit '5'             -- True
isAlpha 'a'             -- True
isUpper 'A'             -- True
isSpace ' '             -- True
isPunctuation '!'        -- True

toUpper 'a'             -- 'A'
toLower 'A'             -- 'a'

digitToInt '5'          -- 5
ord 'A'                  -- 65
chr 65                   -- 'A'

The functions cover ASCII and Unicode classifications. For non-trivial Unicode handling (case folding, normalisation, segmentation), the text-icu package wraps ICU.

Formatting

Haskell does not have a printf-style formatting syntax built into the language; formatting is provided by libraries:

printf

import Text.Printf

main = do
    printf "%d + %d = %d\n" (1 :: Int) (2 :: Int) (3 :: Int)
    printf "%.2f\n" (3.14159 :: Double)
    let s = printf "name=%s age=%d" ("alice" :: String) (30 :: Int) :: String
    putStrLn s

The Text.Printf module mimics C’s printf. The implementation uses Haskell’s polymorphism: printf returns either an IO () action or a String depending on the context. The mechanism is occasionally surprising; the conventional uses are diagnostic output and one-off formatting.

Show

The Show type class admits printing values:

show 42                  -- "42"
show 3.14                 -- "3.14"
show [1, 2, 3]            -- "[1,2,3]"
show (1, "hello")         -- "(1,\"hello\")"

print x = putStrLn (show x)

show is the conventional debug-formatting mechanism; the output is a Haskell-readable representation suitable for read to parse back.

Text.Show.Pretty and pretty-printer

For richer formatting, third-party libraries:

  • Text.Show.Pretty.ppShow produces a pretty-printed Show form.
  • prettyprinter admits a structured formatting model with line breaks and indentation.
  • aeson produces JSON.
  • string-interpolate, interpolatedstring-perl6, and pyf provide string interpolation through Template Haskell.

Modern Haskell code uses Text plus library-specific formatters for substantial output; printf and show cover the simple cases.

Text.Builder and Data.Text.Lazy.Builder

For efficient string building:

import qualified Data.Text.Lazy.Builder as TLB
import qualified Data.Text.Lazy as TL

build :: TL.Text
build = TLB.toLazyText $
    TLB.fromText "name="
    <> TLB.fromText "alice"
    <> TLB.singleton ' '
    <> TLB.fromString "age="
    <> TLB.fromString (show 30)

The Builder type accumulates strings without intermediate allocation; the final toLazyText produces the lazy Text. The mechanism is the conventional choice for performance-sensitive text construction.

Encoding and the Text/ByteString boundary

The conventions:

  • In-memory text: Text (UTF-8 or UTF-16 internally, depending on the version).
  • On-disk and on-wire: ByteString, with explicit encoding/decoding at the boundary.
  • ASCII-only text in performance-sensitive code: ByteString with the understanding that bytes correspond to ASCII characters.

The Data.Text.Encoding module provides the conversions:

import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.ByteString as BS

readUtf8 :: FilePath -> IO T.Text
readUtf8 path = do
    bs <- BS.readFile path
    return (TE.decodeUtf8 bs)

The decodeUtf8 may throw on invalid UTF-8; decodeUtf8' returns Either UnicodeException Text for safe decoding.

Common patterns

String and Text interconversion

import qualified Data.Text as T

stringToText :: String -> T.Text
stringToText = T.pack

textToString :: T.Text -> String
textToString = T.unpack

The pack and unpack operations are O(n) and allocate; for repeated conversion, the cost adds up. Modern code often uses Text throughout and converts only at the boundaries with libraries that require String.

Case-insensitive comparison

import Data.Char (toLower)
import qualified Data.Text as T

eqIgnoreCase :: String -> String -> Bool
eqIgnoreCase a b = map toLower a == map toLower b

eqIgnoreCaseT :: T.Text -> T.Text -> Bool
eqIgnoreCaseT a b = T.toLower a == T.toLower b

For Unicode-aware case folding, T.toCaseFold is preferable to T.toLower.

Parsing numbers from strings

import Text.Read (readMaybe)

parseInt :: String -> Maybe Int
parseInt = readMaybe

parseDouble :: String -> Maybe Double
parseDouble = readMaybe

The Text.Read.readMaybe is the conventional safe parser. For more elaborate parsing, the attoparsec and megaparsec libraries provide combinator-based parsers that work on Text and ByteString.

A note on the choice

The conventional contemporary advice for new Haskell code:

  • Use Text for in-memory text.
  • Use ByteString for binary data, files, and network I/O.
  • Enable OverloadedStrings to admit the same literal syntax for both.
  • Use String only for legacy interop (some libraries’ APIs require it) and for very small, transient text (file paths in some cases).
  • For performance-critical text construction, use Text.Builder or similar.

The choice between Text and ByteString rests on whether the data is text (sequence of characters) or bytes (sequence of bytes). Conflating the two is a common error; the explicit encoding/decoding step is the conventional Haskell discipline for keeping them straight.