Mistakes That No Longer Compile with GADTs

GADTs in a Unison Cloud service, in three before-and-afters

Why GADTs?

Unison 1.5 added GADTs, which let each constructor of a type fix that type's parameters. That may sound like a small thing, but it gives us a way to have the compiler keep track of facts we'd otherwise have to remember ourselves. I've been using them in three places in the Alygn Health service on Unison Cloud: route guards, table definitions, and migrations. Each section below is a before-and-after. The snippets are cut-down stand-ins for the real code, but they live in this project, so the working examples all typecheck (the deliberate mistakes are shown as plain snippets, along with the errors they produce).

Guards that return what they checked

Before, a guard only checked where the request came from, and handed nothing on, so a handler that needed the signed-in reader had to look the session up itself:

►
before.withGuard :
  HttpRequest -> before.Guard -> '{g} HttpResponse ->{g} HttpResponse
before.withGuard request guard handler =
  match guard with
    before.Guard.Unguarded -> handler()
    SameOrigin -> if sameOrigin request then handler() else forbidden
►
before.serveRecords : HttpRequest -> HttpResponse
before.serveRecords request = match authenticate request with
  Some reader -> ok ("records for " Text.++ accountId reader)
  None        -> signInRequired

So, whether a request is signed in was each handler's business rather than its guard's, and nothing stopped us from giving an account endpoint the Unguarded guard in the first place.

After, the session lookup moves into the guard, and the type's index - the type parameter ev - says what a guard proves, and what an endpoint's handler needs in order to run:

So, withGuard doesn't just check the request; it hands the handler the evidence it found:

►
after.withGuard :
  HttpRequest
  -> after.Guard ev
  -> (ev ->{g} HttpResponse)
  ->{g} HttpResponse
after.withGuard request guard handler = match guard with
  after.Guard.Unguarded -> handler()
  SameOriginReader      ->
    match (sameOrigin request, authenticate request) with
      (false, _)          -> forbidden
      (true, Some reader) -> handler reader
      (true, None)        -> signInRequired

►
after.dispatch : after.Endpoint ev -> ev -> HttpResponse
after.dispatch endpoint evidence = match endpoint with
  after.Endpoint.Health         -> ok "up"
  after.Endpoint.AccountRecords -> after.serveRecords evidence
►
after.serveRecords : Reader -> HttpResponse
after.serveRecords reader =
  use Text ++
  ok ("records for " ++ accountId reader)

In after.dispatch, matching on AccountRecords refines ev to Reader, which is why serveRecords evidence typechecks. Better still, an account endpoint left unguarded no longer does:

  after.Endpoint.AccountRecords -> after.Guard.Unguarded

I found a value  of type:  after.Guard Unit
where I expected to find:  after.Guard Reader

One constructor per table

Before, a table's name was just a string, typed out wherever it was used. Here, the accessor and the schema entry each spell out "submissions", and nothing ties the types the accessor claims to the schema's typeLinks:

►
►
named : Text -> NamedTable k v
named name = NamedTable name
►
before.submissions : NamedTable SubmissionId Submission
before.submissions = named "submissions"
►
before.schema : [TableSpec]
before.schema =
  [ TableSpec
      "submissions" (typeLink SubmissionId) (typeLink Submission)
  , TableSpec "accounts" (typeLink AccountId) (typeLink Account)
  , TableSpec
      "submissions-v1"
      (typeLink SubmissionId)
      (typeLink SubmissionV1)
  ]

So, any new call site can claim whatever types it likes. This one compiles, but reading through it would ask for Text where the table stores Submission:

oops : NamedTable SubmissionId Text  -- should be Submission, not Text
oops = NamedTable.named "submissions"

After, each table is declared just once, rather than at every use. Its constructor fixes the key and value types:

Then spec reads a table's name and type links off that constructor (we'll get to how in a moment). Every accessor and the schema read from there, so this replaces both copies from before.

Where before.submissions typed out the name and stated its types by hand, the accessor now just opens the constructor, which supplies both:

►
open : Table k v -> NamedTable k v
open table = named (TableSpec.name (spec table))
►
after.submissions : NamedTable SubmissionId Submission
after.submissions = open Submissions

And where before.schema was a hand-written list, the schema is now read off the same specs. SomeTable hides the indices, so every table fits in one list:

►
►
after.schema : [TableSpec]
after.schema = List.map (cases SomeTable table -> spec table) all

Now the same mistake doesn't make it past the typechecker:

oops : NamedTable SubmissionId Text
oops = after.Table.open after.Table.Submissions

I found a value  of type:  NamedTable SubmissionId Submission
where I expected to find:  NamedTable SubmissionId Text

Who checks the typeLinks?

A schema entry still needs a typeLink for each of a table's types, and a typeLink is just a value: nothing stops a spec from pairing Submissions with typeLink Account. We can't work it out from the constructor's index either, since typeLink needs a concrete type name. What we can do, though, is reach for another GADT: a witness, with one constructor per type a table stores, whose index is the type it stands for:

Each table then names its witnesses, and since parts returns a Parts k v, they have to match the table's own indices. The spec is read off those parts:

►
►
parts : Table k v -> Parts k v
parts = cases
  Submissions   -> Parts "submissions" SubmissionIdS SubmissionS
  Accounts      -> Parts "accounts" AccountIdS AccountS
  SubmissionsV1 -> Parts "submissions-v1" SubmissionIdS SubmissionV1S
►
spec : Table k v -> TableSpec
spec table =
  (Parts name key value) = parts table
  TableSpec name (link key) (link value)

So, give Accounts the wrong witness and it no longer compiles:

after.Table.Accounts -> Parts "accounts" AccountIdS SubmissionS

I found a value  of type:  Parts AccountId Submission
where I expected to find:  Parts AccountId Account

That leaves link as the one unchecked link between a witness and its type, but that's one line per type rather than one per table, and each line names its type twice, so a slip there is hard to miss. It isn't the only thing the compiler leaves to us, though: a table's name in parts is still a string, and all is still a list we keep by hand, so a misspelt name, a name used twice, or a table left out of all all still compile. All told, we end up with four layers:

LayerHoldsOne perChecked at compile time?
Tablea table's key and value typestableyes
partsa table's name and witnessestablethe witnesses are; the name isn't
linka type's typeLinktypeno, but a script does
allevery table, onceschemano, but the same script does

In our service, this made a real difference: each of our 112 table definitions (the 98 live tables, plus older views of a few of them and some retired ones) used to spell out two type links, one for its key and one for its value, so 224 in all. Between them, though, those tables only store 59 different types, and now each of those is linked just once, in 59 lines — a much shorter list to keep an eye on.

Can we do better than keeping an eye on it? An ordinary unit test could repeat the expected links, but that just gives us another hand-written mapping to keep up to date. Instead, UCM lets us compare each constructor's declared index with the link its arm returns: view prints both, using the same names, so comparing the two is just a bit of text processing. I put that in a small Nu script that runs ucm transcript.fork, so it works on a copy of the codebase and doesn't need UCM's lock. It checks the source rather than what the function computes, so it expects each arm to return its link directly, as link does here, and rejects anything else; it exits non-zero on any arm that links the wrong type or that it can't read, which makes it easy to add to a build as a custom lint. Since UCM can print the table type and all too, the same script also checks that all names every table exactly once - Unison checks that a match covers every constructor, but it can't hand us a type's constructors as a list. That leaves the names: one used twice can be caught by an ordinary test over all, but a typo is just a different name, so that one is still on us. We run the same check as part of our release, and our production deploy now refuses any commit that hasn't passed it.

When you Migrate, I hope you are (up)Lifted

Before, a shape migration opened both tables by name and stated their types by hand:

►
before.migrationTwelve : '{Rows} ()
before.migrationTwelve =
  do
    old : [(SubmissionId, SubmissionV1)]
    old = readAll (named "submissions-v1")
    writeAll
      (named "submissions")
      (List.map (cases (key, row) -> (key, addDeficit row)) old)

It's short, but it has the problem from the previous section twice over: two names and a type annotation, none of which are checked against the schema. Swap the two names and it still compiles, then tries to read rows of the new shape as SubmissionV1 when it runs:

old = readAll (NamedTable.named "submissions")
writeAll (NamedTable.named "submissions-v1") (List.map (cases (key, row) -> (key, addDeficit row)) old)

After, the read, convert and write loop is written once, and a migration becomes a value naming two tables and a conversion:

►
type RowLift where
  gadtEvidence.after.RowLift.RowLift :
    Table k old -> Table k new -> (old -> new) -> RowLift
►
liftRows : RowLift ->{Rows} ()
liftRows = cases
  RowLift source target convert ->
    rows = readAll (open source)
    writeAll
      (open target)
      (List.map (cases (key, row) -> (key, convert row)) rows)
►
after.migrationTwelve : RowLift
after.migrationTwelve = RowLift SubmissionsV1 Submissions addDeficit

Admittedly, this is more code for one migration, so why bother? There are two reasons.

First, each row lift after this one is a single declaration, once its conversion is defined. RowLift hides the old and new value types, as SomeTable does, so lifts of different record types fit in one list. In our service, one migration moves four tables as a list of four lifts, and the shared code also reads every converted row back afterwards — a check each earlier shape migration had written out by hand.

Second, the mistakes above no longer compile. The tables' indices fix the direction, so the same swap is caught:

RowLift after.Table.Submissions after.Table.SubmissionsV1 addDeficit

The 3rd argument to `RowLift`
          has type:  SubmissionV1 -> Submission
    but I expected:  Submission -> SubmissionV1

They also fix the key type, so a lift between tables with different keys is caught too:

RowLift after.Table.Accounts after.Table.Submissions (account -> Submission (Account.email account) None)

The 2nd argument to `RowLift`
          has type:  Table SubmissionId Submission
    but I expected:  Table AccountId new

Closing thoughts

Our service has 73 endpoints and 98 tables, so this wasn't just a toy example. The derived schema came out equal to the hand-written list it replaced, so the change needed no data migration; here, theSameSchemaEitherWay checks the same thing.

If you'd like to use any of this without writing it yourself, the table and migration pieces are in @nusentry/blueprint as of 8.5.0: blueprint.lift for row lifts, and TypedSpec for table specs checked through a witness.