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) forheadandtail; O(n) forlastandinit.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:
| Operation | Effect |
|---|---|
T.length | O(1) length |
T.append | Concatenation |
T.head, T.last, T.tail, T.init | List-style accessors |
T.take, T.drop, T.splitAt | Slicing |
T.toUpper, T.toLower | Case conversion |
T.replace | Substring replacement |
T.split, T.splitOn, T.words, T.lines | Splitting |
T.unwords, T.unlines, T.intercalate | Joining |
T.reverse | Reverse |
T.find, T.findIndex | Search |
T.isPrefixOf, T.isSuffixOf, T.isInfixOf | Membership |
T.toLazyText, T.toStrict | Conversions 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
ByteStringfor transport. - Binary formats —
Data.ByteString.Builderfor 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.ppShowproduces a pretty-printedShowform.prettyprinteradmits a structured formatting model with line breaks and indentation.aesonproduces JSON.string-interpolate,interpolatedstring-perl6, andpyfprovide 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:
ByteStringwith 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
Textfor in-memory text. - Use
ByteStringfor binary data, files, and network I/O. - Enable
OverloadedStringsto admit the same literal syntax for both. - Use
Stringonly 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.Builderor 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.