<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Branimir Klarić</title>
    <description>The latest articles on DEV Community by Branimir Klarić (@bklaric).</description>
    <link>https://dev.to/bklaric</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4152790%2Ffae19d3e-d25e-4d67-8629-3c16e998b9f5.jpg</url>
      <title>DEV Community: Branimir Klarić</title>
      <link>https://dev.to/bklaric</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bklaric"/>
    <language>en</language>
    <item>
      <title>One route type, two sides: a PureScript client and server that share their HTTP API</title>
      <dc:creator>Branimir Klarić</dc:creator>
      <pubDate>Wed, 30 Sep 2026 17:39:53 +0000</pubDate>
      <link>https://dev.to/bklaric/one-route-type-two-sides-a-purescript-client-and-server-that-share-their-http-api-374l</link>
      <guid>https://dev.to/bklaric/one-route-type-two-sides-a-purescript-client-and-server-that-share-their-http-api-374l</guid>
      <description>&lt;p&gt;&lt;a href="https://www.teamtavern.net" rel="noopener noreferrer"&gt;TeamTavern&lt;/a&gt; is a site where people find teammates for online games. Its browser app and its API server are both written in PureScript, in one codebase, compiled by one &lt;code&gt;spago build&lt;/code&gt;. The part of that worth writing about is how the two sides talk: every HTTP endpoint is a single type, and both the server's handler and the client's call are derived from it.&lt;/p&gt;

&lt;p&gt;This post walks through how that works, with code from the &lt;a href="https://github.com/bklaric/team-tavern" rel="noopener noreferrer"&gt;repository&lt;/a&gt;. The routing library is Jarilo, part of &lt;a href="https://github.com/bklaric/purescript-bklaric" rel="noopener noreferrer"&gt;purescript-bklaric&lt;/a&gt;. If you know Haskell's Servant, the idea will be familiar.&lt;/p&gt;

&lt;h2&gt;
  
  
  A route is a type
&lt;/h2&gt;

&lt;p&gt;Here is the route that signs a player in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight haskell"&gt;&lt;code&gt;&lt;span class="kr"&gt;type&lt;/span&gt; &lt;span class="kt"&gt;StartSession&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="kt"&gt;PostJson_&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Literal&lt;/span&gt; &lt;span class="s"&gt;"sessions"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;RequestContent&lt;/span&gt;
    &lt;span class="o"&gt;==&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;NoContent&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="kt"&gt;BadRequestJson&lt;/span&gt; &lt;span class="kt"&gt;BadContent&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="kt"&gt;Internal_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kr"&gt;type&lt;/span&gt; &lt;span class="kt"&gt;RequestContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Variant&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;emailOrNickname&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;discord&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;accessToken&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kr"&gt;type&lt;/span&gt; &lt;span class="kt"&gt;BadContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Variant&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="n"&gt;unknownPlayer&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wrongPassword&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unknownDiscord&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;nickname&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it left to right: a &lt;code&gt;POST&lt;/code&gt; to &lt;code&gt;/sessions&lt;/code&gt; with a JSON body, answered with &lt;code&gt;204 No Content&lt;/code&gt;, or a &lt;code&gt;400&lt;/code&gt; carrying one of three reasons, or a &lt;code&gt;500&lt;/code&gt;. Nothing else.&lt;/p&gt;

&lt;p&gt;There is no value of type &lt;code&gt;StartSession&lt;/code&gt;. The pieces are declared as bare type-level data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight haskell"&gt;&lt;code&gt;&lt;span class="n"&gt;foreign&lt;/span&gt; &lt;span class="kr"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;data&lt;/span&gt; &lt;span class="kt"&gt;Literal&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="kt"&gt;Symbol&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Path&lt;/span&gt;
&lt;span class="n"&gt;foreign&lt;/span&gt; &lt;span class="kr"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;data&lt;/span&gt; &lt;span class="kt"&gt;Capture&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="kt"&gt;Symbol&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Type&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Path&lt;/span&gt;
&lt;span class="n"&gt;foreign&lt;/span&gt; &lt;span class="kr"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;data&lt;/span&gt; &lt;span class="kt"&gt;PathChain&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="kt"&gt;Path&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Path&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Path&lt;/span&gt;

&lt;span class="kr"&gt;infixr&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt; &lt;span class="kr"&gt;type&lt;/span&gt; &lt;span class="kt"&gt;PathChain&lt;/span&gt; &lt;span class="n"&gt;as&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PostJson_&lt;/code&gt;, &lt;code&gt;NoContent&lt;/code&gt; and the rest are type synonyms over a handful of these. A route with path parameters looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight haskell"&gt;&lt;code&gt;&lt;span class="kr"&gt;type&lt;/span&gt; &lt;span class="kt"&gt;ViewPost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="kt"&gt;Get_&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Literal&lt;/span&gt; &lt;span class="s"&gt;"games"&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="kt"&gt;Capture&lt;/span&gt; &lt;span class="s"&gt;"handle"&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="kt"&gt;Literal&lt;/span&gt; &lt;span class="s"&gt;"posts"&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="kt"&gt;Capture&lt;/span&gt; &lt;span class="s"&gt;"id"&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;==&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;OkJson&lt;/span&gt; &lt;span class="kt"&gt;OkContent&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="kt"&gt;NotFound_&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="kt"&gt;Internal_&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each route lives in its own module under &lt;code&gt;Routes/&lt;/code&gt;, next to the record and variant types of its bodies. &lt;code&gt;Server/&lt;/code&gt; and &lt;code&gt;Client/&lt;/code&gt; both import them from there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The server: a record of handlers
&lt;/h2&gt;

&lt;p&gt;All the routes are joined into one type, each under a name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight haskell"&gt;&lt;code&gt;&lt;span class="kr"&gt;type&lt;/span&gt; &lt;span class="kt"&gt;SessionRoutes&lt;/span&gt;
    &lt;span class="o"&gt;=&lt;/span&gt;   &lt;span class="s"&gt;"startSession"&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;StartSession&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;|&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"endSession"&lt;/span&gt;   &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;EndSession&lt;/span&gt;

&lt;span class="kr"&gt;type&lt;/span&gt; &lt;span class="kt"&gt;AllRoutes&lt;/span&gt;
    &lt;span class="o"&gt;=&lt;/span&gt;    &lt;span class="kt"&gt;SessionRoutes&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;|&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;PasswordRoutes&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;|&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;PlayerRoutes&lt;/span&gt;
    &lt;span class="c1"&gt;-- ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server is one call that takes that type and a record with a handler per name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight haskell"&gt;&lt;code&gt;&lt;span class="n"&gt;serve&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Proxy&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="kr"&gt;_&lt;/span&gt; &lt;span class="kt"&gt;AllRoutes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;serveOptions&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;startSession&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;\&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
        &lt;span class="kt"&gt;Session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="n"&gt;environment&lt;/span&gt; &lt;span class="n"&gt;mailer&lt;/span&gt; &lt;span class="n"&gt;discordApiUrl&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt; &lt;span class="n"&gt;cookies&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;
    &lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;viewPost&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;\&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cookies&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;viewPost&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handle&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;cookies&lt;/span&gt;
    &lt;span class="c1"&gt;-- ...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;serve&lt;/code&gt; has a constraint, &lt;code&gt;JunctionRouter junction handlers&lt;/code&gt;, with a functional dependency from the routes to the record. So the compiler works out the record's type from &lt;code&gt;AllRoutes&lt;/code&gt;: which fields it has, what each handler receives and what it may return.&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;viewPost&lt;/code&gt;, &lt;code&gt;path&lt;/code&gt; is &lt;code&gt;{ handle :: String, id :: Int }&lt;/code&gt;, because the route captures a &lt;code&gt;String&lt;/code&gt; named &lt;code&gt;handle&lt;/code&gt; and an &lt;code&gt;Int&lt;/code&gt; named &lt;code&gt;id&lt;/code&gt;. The router has already parsed the segment into an &lt;code&gt;Int&lt;/code&gt; by the time the handler runs; a request for &lt;code&gt;/games/valorant/posts/abc&lt;/code&gt; is answered before any of my code sees it. For &lt;code&gt;startSession&lt;/code&gt;, &lt;code&gt;body&lt;/code&gt; is the &lt;code&gt;RequestContent&lt;/code&gt; variant, already decoded.&lt;/p&gt;

&lt;p&gt;The return type is derived the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight haskell"&gt;&lt;code&gt;&lt;span class="kt"&gt;RequestResult&lt;/span&gt; &lt;span class="n"&gt;pathParams&lt;/span&gt; &lt;span class="n"&gt;queryParams&lt;/span&gt; &lt;span class="n"&gt;realBody&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Async&lt;/span&gt; &lt;span class="kt"&gt;Void&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Variant&lt;/span&gt; &lt;span class="n"&gt;responseRow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things follow from it.&lt;/p&gt;

&lt;p&gt;The handler's error type is &lt;code&gt;Void&lt;/code&gt;. It cannot fail; whatever goes wrong inside has to become one of the route's responses. In practice every handler is wrapped in a small function that turns its typed error into the matching response and logs it only when it is the &lt;code&gt;internal&lt;/code&gt; one.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;responseRow&lt;/code&gt; holds exactly the responses the route declares. &lt;code&gt;StartSession&lt;/code&gt; has no &lt;code&gt;404&lt;/code&gt;, so a handler for it that tries to answer &lt;code&gt;notFound&lt;/code&gt; does not compile. Neither does a server with a route in &lt;code&gt;AllRoutes&lt;/code&gt; and no handler for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The client: the same type, read the other way
&lt;/h2&gt;

&lt;p&gt;On the client, a request is a call to &lt;code&gt;fetch&lt;/code&gt; with the route as a proxy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight haskell"&gt;&lt;code&gt;&lt;span class="kr"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;Fetch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="kt"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;pathParams&lt;/span&gt; &lt;span class="n"&gt;queryParams&lt;/span&gt; &lt;span class="n"&gt;realBody&lt;/span&gt; &lt;span class="n"&gt;responses&lt;/span&gt;
    &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pathParams&lt;/span&gt; &lt;span class="n"&gt;queryParams&lt;/span&gt; &lt;span class="n"&gt;realBody&lt;/span&gt; &lt;span class="n"&gt;responses&lt;/span&gt; &lt;span class="kr"&gt;where&lt;/span&gt;
    &lt;span class="n"&gt;fetch&lt;/span&gt;
        &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="kt"&gt;Proxy&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Record&lt;/span&gt; &lt;span class="n"&gt;pathParams&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Record&lt;/span&gt; &lt;span class="n"&gt;queryParams&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;realBody&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Promise&lt;/span&gt; &lt;span class="kt"&gt;FetchError&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Variant&lt;/span&gt; &lt;span class="n"&gt;responses&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The functional dependency again does the work. The route decides the method, builds the URL from the path and query records, encodes the body and sets its &lt;code&gt;Content-Type&lt;/code&gt;. A call for a post is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight haskell"&gt;&lt;code&gt;&lt;span class="n"&gt;fetchPath&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Proxy&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="kr"&gt;_&lt;/span&gt; &lt;span class="kt"&gt;ViewPost&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leave out &lt;code&gt;id&lt;/code&gt;, or pass it as a &lt;code&gt;String&lt;/code&gt;, and it does not compile.&lt;/p&gt;

&lt;p&gt;What comes back is a &lt;code&gt;Variant&lt;/code&gt; with a case per declared response, its JSON body already decoded into the route's type. Signing in with Discord, from the sign-in page, trimmed a little:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight haskell"&gt;&lt;code&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="n"&gt;fetchBody&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Proxy&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="kr"&gt;_&lt;/span&gt; &lt;span class="kt"&gt;StartSession&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inj&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Proxy&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="kr"&gt;_&lt;/span&gt; &lt;span class="s"&gt;"discord"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;accessToken&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="kr"&gt;case&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="kr"&gt;of&lt;/span&gt;
    &lt;span class="kt"&gt;Right&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;#&lt;/span&gt; &lt;span class="n"&gt;onMatch&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;noContent&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;const&lt;/span&gt; &lt;span class="o"&gt;$&lt;/span&gt; &lt;span class="n"&gt;navigateReplace_&lt;/span&gt; &lt;span class="n"&gt;back&lt;/span&gt;
        &lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;badRequest&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;onMatch&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;unknownDiscord&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;\&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;nickname&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
                &lt;span class="n"&gt;set&lt;/span&gt; &lt;span class="kr"&gt;_&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;screen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Nickname&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;accessToken&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;nickname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nickname&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;const&lt;/span&gt; &lt;span class="o"&gt;$&lt;/span&gt; &lt;span class="n"&gt;failWith&lt;/span&gt; &lt;span class="n"&gt;somethingWrong&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;const&lt;/span&gt; &lt;span class="o"&gt;$&lt;/span&gt; &lt;span class="n"&gt;failWith&lt;/span&gt; &lt;span class="n"&gt;somethingWrong&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kt"&gt;Left&lt;/span&gt; &lt;span class="kr"&gt;_&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;failWith&lt;/span&gt; &lt;span class="n"&gt;somethingWrong&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;nickname&lt;/code&gt; the page prefills for a new player is the field the server put in &lt;code&gt;unknownDiscord&lt;/code&gt;. There is no step where the client's idea of that payload could drift from the server's, because there is only one definition of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a change looks like
&lt;/h2&gt;

&lt;p&gt;The site has 47 routes. When one changes, the compiler finds what the change touches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rename a field in a response body, and the handler that builds it and every page that reads it fail to compile.&lt;/li&gt;
&lt;li&gt;Add a path capture, and every &lt;code&gt;fetchPath&lt;/code&gt; call for that route is missing a field.&lt;/li&gt;
&lt;li&gt;Remove a response, and the handler that returned it and the pages that matched on it by name fail.&lt;/li&gt;
&lt;li&gt;Add a route to &lt;code&gt;AllRoutes&lt;/code&gt;, and the server does not build until it has a handler.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One case the compiler does not force. Adding a response to a route leaves existing client code compiling wherever it matches with &lt;code&gt;onMatch&lt;/code&gt; and a default branch, as above. That is a choice: &lt;code&gt;match&lt;/code&gt; instead would make every call site exhaustive, at the cost of spelling out &lt;code&gt;internal&lt;/code&gt; everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the types stop
&lt;/h2&gt;

&lt;p&gt;The route type describes what my server sends. It does not describe everything a browser can receive.&lt;/p&gt;

&lt;p&gt;The reverse proxy in front of the API refuses a body over 64 KB with a &lt;code&gt;413&lt;/code&gt;, a status no route declares. A deploy can leave a stale client talking to a newer server for a few minutes. &lt;code&gt;fetch&lt;/code&gt; handles both the same way: a status the route does not list is a &lt;code&gt;FetchError&lt;/code&gt;, not a &lt;code&gt;Variant&lt;/code&gt; case, so the page's &lt;code&gt;Left&lt;/code&gt; branch runs.&lt;/p&gt;

&lt;p&gt;The site also records when that happens. The client's wrapper around &lt;code&gt;fetch&lt;/code&gt; reports to the server any response the route does not declare, and any &lt;code&gt;400&lt;/code&gt;, &lt;code&gt;401&lt;/code&gt;, &lt;code&gt;403&lt;/code&gt; or &lt;code&gt;404&lt;/code&gt; that the call site did not say it expects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight haskell"&gt;&lt;code&gt;&lt;span class="n"&gt;fetchBody&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expecting&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s"&gt;"badRequest"&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Proxy&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="kr"&gt;_&lt;/span&gt; &lt;span class="kt"&gt;UpdateEmail&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those labels are plain strings, and the compiler doesn't check them. Misspell one as &lt;code&gt;"badRequets"&lt;/code&gt; and the code still compiles; the only sign is a report in the log the next time that call gets a &lt;code&gt;400&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The other boundary is the database. Rows are decoded from SQL results at runtime, so a query that returns the wrong shape fails when it runs, not when it compiles. The route types guarantee that what leaves the handler matches what the client expects; they say nothing about whether the handler could produce it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same types, read a third way
&lt;/h2&gt;

&lt;p&gt;The server and the client don't share code. They share a type, and each reads it through its own type classes: &lt;code&gt;JunctionRouter&lt;/code&gt; on one side, &lt;code&gt;Fetch&lt;/code&gt; on the other. Nothing stops a third class from reading the same routes for another purpose.&lt;/p&gt;

&lt;p&gt;TeamTavern already has a small one. When the client reports a failed request, it logs the method and path but never the query string, which can carry the one-time codes from links in emails. The line comes from the route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight haskell"&gt;&lt;code&gt;&lt;span class="kr"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;RequestLine&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="kt"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;pathParams&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pathParams&lt;/span&gt; &lt;span class="kr"&gt;where&lt;/span&gt;
    &lt;span class="n"&gt;requestLine&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="kt"&gt;Proxy&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Record&lt;/span&gt; &lt;span class="n"&gt;pathParams&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bigger use is documentation. An OpenAPI document, the format Swagger UI and most API tooling read, is mostly what a route type already says: the method, the path with its parameters, the query parameters, the request body and a response for each status. Haskell's Servant has libraries that generate one from its route types, and the same is possible here.&lt;/p&gt;

&lt;p&gt;The path is the easy part. This class turns a Jarilo path into OpenAPI's template syntax:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight haskell"&gt;&lt;code&gt;&lt;span class="kr"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;OpenApiPath&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="kt"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kr"&gt;where&lt;/span&gt;
    &lt;span class="n"&gt;openApiPath&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="kt"&gt;Proxy&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;

&lt;span class="kr"&gt;instance&lt;/span&gt; &lt;span class="kt"&gt;IsSymbol&lt;/span&gt; &lt;span class="n"&gt;literal&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;OpenApiPath&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Literal&lt;/span&gt; &lt;span class="n"&gt;literal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kr"&gt;where&lt;/span&gt;
    &lt;span class="n"&gt;openApiPath&lt;/span&gt; &lt;span class="kr"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;reflectSymbol&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Proxy&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="kr"&gt;_&lt;/span&gt; &lt;span class="n"&gt;literal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kr"&gt;instance&lt;/span&gt; &lt;span class="kt"&gt;IsSymbol&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;OpenApiPath&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Capture&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kr"&gt;where&lt;/span&gt;
    &lt;span class="n"&gt;openApiPath&lt;/span&gt; &lt;span class="kr"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/{"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;reflectSymbol&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Proxy&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="kr"&gt;_&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"}"&lt;/span&gt;

&lt;span class="kr"&gt;instance&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;OpenApiPath&lt;/span&gt; &lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;OpenApiPath&lt;/span&gt; &lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;OpenApiPath&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;PathChain&lt;/span&gt; &lt;span class="n"&gt;left&lt;/span&gt; &lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kr"&gt;where&lt;/span&gt;
    &lt;span class="n"&gt;openApiPath&lt;/span&gt; &lt;span class="kr"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;openApiPath&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Proxy&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="kr"&gt;_&lt;/span&gt; &lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;openApiPath&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Proxy&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="kr"&gt;_&lt;/span&gt; &lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For &lt;code&gt;ViewPost&lt;/code&gt; it gives &lt;code&gt;/games/{handle}/posts/{id}&lt;/code&gt;. The same pattern extends to the method, a parameter list from the &lt;code&gt;Capture&lt;/code&gt; and query types, and a status list from the responses, and a class over &lt;code&gt;AllRoutes&lt;/code&gt; then walks every route.&lt;/p&gt;

&lt;p&gt;The bodies take more work. Each record, array, &lt;code&gt;Maybe&lt;/code&gt; and &lt;code&gt;Variant&lt;/code&gt; needs a JSON Schema describing the JSON its codec actually produces, so that class has to follow the codec's choices, such as how a variant is tagged. That's more code than the rest together, but it's written once in the library, not once per route.&lt;/p&gt;

&lt;p&gt;TeamTavern doesn't generate one: its only client is its own pages, which get the types directly. An API with outside consumers would, and those consumers would get documentation that is never out of date, because it's produced from the same type the server has to satisfy. The same goes for anything else derived from the routes: a list of endpoints for a test to visit, TypeScript declarations for a client in another language, a mock server that answers with the declared shapes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is it worth it?
&lt;/h2&gt;

&lt;p&gt;For me, yes. It moves a whole category of bugs from run time to compile time: a client sending a body the server can't read, a page reading a field the server stopped sending, a URL built with a parameter missing, a handler answering with a status the route never declared. Without shared types, each of those surfaces when someone clicks through the page, or when a user does. With them, it surfaces as a compile error pointing at the line.&lt;/p&gt;

&lt;p&gt;That speeds development up more than it slows it down. A change to an endpoint starts with the route type, and the compiler hands back the list of places to update, on both sides at once. When it builds, the two sides agree, and what's left to test is whether the page does the right thing, not whether it talks to the server correctly.&lt;/p&gt;

&lt;p&gt;The code is at &lt;a href="https://github.com/bklaric/team-tavern" rel="noopener noreferrer"&gt;github.com/bklaric/team-tavern&lt;/a&gt;, under AGPL-3.0: routes in &lt;code&gt;src/TeamTavern/Routes&lt;/code&gt;, handlers in &lt;code&gt;Server&lt;/code&gt;, pages in &lt;code&gt;Client&lt;/code&gt;. The site it runs is &lt;a href="https://www.teamtavern.net" rel="noopener noreferrer"&gt;teamtavern.net&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>purescript</category>
      <category>functional</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
