Search code examples
postgresqlresthaskellservant

Idiomatic way to define new vs persisted types in Haskell


I have a type that represents a persisted record. I want to have a very similar type that represents data that should be POSTed to create a new record.

This is the full type:

data Record = Reading
  { id: UUID
  , value: String
  ...
  }

the "new" type is the same minus the "id", which will be auto-generated by the db. How can I define this type? I am using servant to define the API.

My current strategy is to prefix the type and all fields with "new", which works but is reduntant for many-field models. I have also seen the nested strategy where I have a common shared type. I've also thought about making the id optional, but I really don't want to be able to post it.


Solution

  • You can implement this with a Higher Kinded Data like approach.

    First, some imports:

    {-# LANGUAGE StandaloneDeriving #-}
    {-# LANGUAGE UndecidableInstances #-}
    module Example where
    
    import Data.Functor.Identity
    import Data.Proxy
    import Data.UUID 
    import Data.Aeson
    import GHC.Generics
    

    Then, define a record with a higher kinded type parameter:

    data Record f = Record  { recordId :: f UUID, recordValue :: String } deriving (Generic)
    

    The Identity Functor gives you a variant on this record which always has an Id.

    type RecordWithId = Record Identity
    

    Using Maybe gives you a variant with an optional id.

    type RecordWithOptionalId = Record Maybe 
    

    Proxy can be used as a Functor with a single uninteresting "unit" value. (and no values of the wrapped type). This lets us create a type for a Record with no ID.

    type RecordWithoutId = Record (Proxy)
    

    We can derive Show for our Record.

    deriving instance (Show (f UUID)) => Show (Record f)
    

    Passing omitNothingFields = True and allowOmitedFields = True in the Aeson instances is required to parse a RecordWithoutId as you'd expect. This does require a version of Aeson >= 2.2.0.0 (which as of writing is more recent than the latest Stackage Snapshot). You could probably implement the Aeson instances by hand if this version bound doesn't work for you.

    instance (ToJSON (f UUID)) => ToJSON (Record f) where
        toJSON = genericToJSON defaultOptions { omitNothingFields = True, allowOmitedFields = True }
    
    instance (FromJSON (f UUID)) => FromJSON (Record f) where
        parseJSON = genericParseJSON defaultOptions { omitNothingFields = True, allowOmitedFields = True }
    

    Encoding a value with an ID:

    ghci> import qualified Data.ByteString.Lazy.Char8 as BL
    ghci> BL.putStrLn $ encode (Record {recordId = Identity nil, recordValue = "value" })
    {"recordId":"00000000-0000-0000-0000-000000000000","recordValue":"value"}
    

    Encoding a value without an ID:

    ghci> BL.putStrLn $ encode (Record {recordId = Proxy, recordValue = "value" })
    {"recordValue":"value"}
    

    Decoding a value without an ID

    ghci> decode "{\"recordValue\":\"value\"}" :: Maybe RecordWithoutId
    Just (Record {recordId = Proxy, recordValue = "value"})