5 min read
On this page

Web Services with Servant

Servant is the unusual one in the Haskell web ecosystem. Where Yesod and Scotty give you handler-based routing similar to Flask or Express, Servant defines your API at the type level and derives the server, client, and documentation from the same type. This sounds like academic indulgence, in practice it eliminates an entire class of bugs around request parsing and response shaping.

The pitch: write the API once as a type. Get a type-checked server. Get a type-checked client (in Haskell, JavaScript, or other languages with code generators). Get OpenAPI/Swagger docs. All three stay in sync because they're projections of the same definition.

A simple API as a type

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeOperators #-}

import Servant

type UserAPI =
       "users" :> Get '[JSON] [User]
  :<|> "users" :> Capture "uid" Int :> Get '[JSON] User
  :<|> "users" :> ReqBody '[JSON] NewUser :> Post '[JSON] User

Read this as: GET /users returns [User] as JSON, GET /users/:uid returns one User, POST /users with a JSON body of NewUser returns the created User. The :<|> combines endpoints, :> chains path segments and modifiers.

The handler type is derived from the API type. If you write a handler that returns the wrong type or expects the wrong body, it won't compile.

The server

import Servant
import Network.Wai.Handler.Warp (run)

server :: Server UserAPI
server = listUsers :<|> getUser :<|> createUser
  where
    listUsers :: Handler [User]
    listUsers = liftIO $ queryAllUsers

    getUser :: Int -> Handler User
    getUser uid = do
      mu <- liftIO $ queryUser uid
      maybe (throwError err404) return mu

    createUser :: NewUser -> Handler User
    createUser nu = liftIO $ insertUser nu

main :: IO ()
main = run 8080 (serve (Proxy :: Proxy UserAPI) server)

The Server UserAPI is a tuple-like type whose components match the endpoint shapes. Add an endpoint to the type, the compiler tells you which handler is missing. Remove one, the compiler tells you about the dead handler. Refactor a request type, every relevant handler signature changes.

Handler is ExceptT ServerError IO under the hood. You return values, throw err404 (and friends) for HTTP errors, and use liftIO for IO.

Generating a client

import Servant.Client

listUsersC  :: ClientM [User]
getUserC    :: Int -> ClientM User
createUserC :: NewUser -> ClientM User

listUsersC :<|> getUserC :<|> createUserC =
  client (Proxy :: Proxy UserAPI)

The client functions have the same shape as the handlers but without the Handler wrapper. They take whatever the endpoint takes and return ClientM, which you run against a base URL.

import Network.HTTP.Client
import Servant.Client

main :: IO ()
main = do
  mgr <- newManager defaultManagerSettings
  let env = mkClientEnv mgr (BaseUrl Http "localhost" 8080 "")
  result <- runClientM (getUserC 42) env
  print result

This is the same code path the server uses to parse requests, run in reverse. If the server expects an Int capture, the client requires an Int. If you change the type to UUID, both sides change at once. The compiler enforces consistency.

For non-Haskell clients, servant-js, servant-py, and servant-typescript generate client code in those languages from the same API type. Mercury and Channable use this pattern in production, the front-end TypeScript types are generated from the Haskell API definition.

Authentication and middleware

Servant has combinators for common needs:

type ProtectedAPI =
  "me" :> AuthProtect "jwt" :> Get '[JSON] User

type API =
       PublicAPI
  :<|> ProtectedAPI

AuthProtect "jwt" is a handler-defined auth scheme. You provide a AuthHandler that extracts the user from the request, and the handler for me receives a User argument as its first parameter, type-safe authentication.

For HTTP basic auth, JWT, OAuth, etc., there are standard combinators or community libraries.

OpenAPI documentation

import Servant.OpenApi
import Data.OpenApi

apiDocs :: OpenApi
apiDocs = toOpenApi (Proxy :: Proxy UserAPI)

You serve this at /openapi.json and point Swagger UI at it. The docs are derived from types, so when you change the API the docs update automatically. No more stale Swagger files.

Servant vs Yesod vs Scotty

Scotty is the simplest. Sinatra-style, dynamic routing, you write handlers that pull values from the request. Easy to start, no type-level machinery. Good for small services and prototypes:

import Web.Scotty

main = scotty 8080 $ do
  get "/users/:id" $ do
    uid <- param "id"
    user <- liftIO $ queryUser uid
    json user

If id isn't an integer, you get a runtime error. If you forget to handle a case, you find out in production.

Yesod is the heavyweight. Type-safe URLs, integrated database layer (with persistent), authentication, sessions, an HTML templating system (Hamlet). It's a full framework like Rails. Production users include FP Complete and Snowdrift. The cost is a steep learning curve and a lot of moving parts.

Servant sits between them. Less framework than Yesod, more type safety than Scotty. The right choice for most API services, especially when you have non-Haskell clients consuming the API or when the API surface is large enough that codegen is valuable.

A complete CRUD example

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeOperators #-}

module Main where

import Data.Aeson
import Data.IORef
import qualified Data.Map.Strict as Map
import GHC.Generics
import Network.Wai.Handler.Warp
import Servant

data Todo = Todo
  { todoId   :: Int
  , title    :: String
  , done     :: Bool
  } deriving (Generic, Show)

instance ToJSON Todo
instance FromJSON Todo

data NewTodo = NewTodo { newTitle :: String }
  deriving (Generic, Show)

instance ToJSON NewTodo
instance FromJSON NewTodo

type TodoAPI =
       "todos" :> Get '[JSON] [Todo]
  :<|> "todos" :> Capture "tid" Int :> Get '[JSON] Todo
  :<|> "todos" :> ReqBody '[JSON] NewTodo :> Post '[JSON] Todo
  :<|> "todos" :> Capture "tid" Int :> Delete '[JSON] NoContent

type Store = IORef (Int, Map.Map Int Todo)

server :: Store -> Server TodoAPI
server store = list :<|> get_ :<|> create :<|> delete_
  where
    list = do
      (_, m) <- liftIO (readIORef store)
      return (Map.elems m)
    get_ tid = do
      (_, m) <- liftIO (readIORef store)
      maybe (throwError err404) return (Map.lookup tid m)
    create (NewTodo t) = liftIO $ atomicModifyIORef' store $ \(n, m) ->
      let n' = n + 1
          todo = Todo n' t False
      in ((n', Map.insert n' todo m), todo)
    delete_ tid = do
      liftIO $ atomicModifyIORef' store $ \(n, m) -> ((n, Map.delete tid m), ())
      return NoContent

main :: IO ()
main = do
  store <- newIORef (0, Map.empty)
  run 8080 (serve (Proxy :: Proxy TodoAPI) (server store))

This compiles, runs, and gives you a fully type-checked CRUD API in about 50 lines. Swap IORef for a database connection (with postgresql-simple or persistent) and you have a real service.

Servant in production

The main complaints about Servant are:

  • Compile times. Type-level programming is slow. A large API can take 30+ seconds to type-check incrementally. There are workarounds (split the API across modules, use NamedRoutes for better organization).
  • Error messages. When a server doesn't match the API type, the GHC error can be a wall of text about type mismatches. With practice you learn to read them, and NamedRoutes (Servant 0.19+) makes this much better.
  • Custom combinators. Writing a new combinator requires understanding the type-level machinery. Most users never need to.

In exchange you get an API definition that doesn't drift from the implementation, free codegen for clients in multiple languages, and free OpenAPI docs. For a service with multiple consumers, this trade-off is overwhelmingly worth it.

Common Pitfalls

Forgetting DataKinds and TypeOperators. The API types use type-level strings and operators that need these extensions enabled.

Mixing endpoint order between API type and server. The server tuple has to be in the same order as the :<|> chain in the type. With many endpoints, this gets fragile, switch to NamedRoutes which uses records and named fields instead.

Generic JSON instances on big types. deriving Generic plus instance ToJSON Foo is convenient but the generated instance includes every field, including ones you didn't intend to expose. Be deliberate about what your API types contain.

Heavy types in the API. The API type is processed by the type checker on every change. If your API has hundreds of endpoints, consider splitting into multiple smaller APIs combined with :<|> at the top level.

Treating Servant errors as production-friendly. The default error responses are bare-bones. Wrap errors in your own JSON envelope, especially if non-Haskell clients consume the API.

Key Takeaways

Servant defines APIs as types. Server, client, and docs are all derived from the same definition, which means they can't drift.

The trade-off is compile time and a learning curve for the type-level constructs. The payoff is a class of bugs (request parsing mismatches, route typos, client/server schema drift) that simply can't happen.

For most API services in 2026, Servant is the right default. Scotty for tiny one-off services, Yesod for full-stack apps with HTML rendering, Servant for APIs.