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
NamedRoutesfor 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.