Search code examples
haskellrecordghc

How to suppress missing field warnings for record fields isomorphic to void?


Lets say I have a datatype like the following:

data File' key generated value = File
{
  id :: key Int,
  md5Hash :: generated Text,
  contents :: value Text,
  description :: value Text
  .
  .
}

then I can then have things like this:

type FileCreate = File' (Const Void) (Const Void) Identity
type FileUpdate = File' Identity (Const Void) Maybe
type FileLookup = File' Identity (Const Void) (Const Void)
type File = File' Identity Identity Identity

But here's the annoying thing.

Lets say I want to do a file lookup, so I do it like so:

FileLookup { id = Identity 1234 }

Then I'm going to get warnings about unassigned record fields. I want to keep these warnings on, because if I do:

FileCreate { description = Identity "This file hasn't had 'contents' assigned so this should be an error" }

then this is an error, and I want the warning to alert me about this. But in the case of FileLookup, the other fields are Const Void. They have no valid values anyway, as Const Void is newtyped to Void.

Here my options seem to be:

  1. Make a function fileCreate :: Text -> Text -> FileCreate, fileUpdate :: Int -> Text -> Text -> FileUpdate, fileKey :: Int -> FileLookup etc, but this seems boilerplatey and I lose the nice record syntax.
  2. I could use the GHC extension 'record patterns' to create separate patterns similarly as point 1, but that's still a lot of boilerplate, particularly as I'll have to create 3 or 4 specialised record pattern synonyms for EVERY new datatype I define.

In my actual problem I'm likely to have lots of these "File" like datatypes but probably only around 5 patterns (insert, update, lookup etc). So happy to do some boilerplate for these patterns as long as I don't have to repeat it for every new datatype.

Happy to use Generics if it helps. Would like to avoid Template Haskell if possible but if it's the only way I guess I'll deal. But ideally I'd like something like

{-# PRAGMA DontWarnIfThisParticularTypeOrANewtypeOfThisParticularTypeIsNotInitialisedInARecord #-}
data MyConstVoid a -- no constructors

and just use MyConstVoid instead of Const Void (which I'm happy to do). I just can't find a warning suppressor like that.


Solution

  • Why I don't think this is a great design

    The trouble is those fields are uninitialised (which mean they contain bottom). And you're relying on them containing bottom, since Const Void has no non-bottom inhabitants. You want GHC to understand your intentions so that it can warn about some uninitialised fields but not others.

    I suppose I can see why "this field can't be initialised (except to bottom)" might be expected to suppress a warning about not initialising the field. The trouble is that doesn't actually fit the idioms around how Void is used.

    In particular, your type says that getConst . value applied to a FileLookup is supposed to produce a value of type Void. We've all agreed that once you have a Void, you can use absurd :: Void -> a to convert it to any type you like. For example, I was able to write this program using your suggested types:

    import Data.Functor.Identity
    import Data.Void
    import Data.Text
    import Control.Applicative
    
    data File' key generated value = File {
      id' :: key Int,
      md5Hash :: generated Text,
      contents :: value Text,
      description :: value Text
    }
    
    
    type FileCreate = File' (Const Void) (Const Void) Identity
    type FileUpdate = File' Identity (Const Void) Maybe
    type FileLookup = File' Identity (Const Void) (Const Void)
    type File = File' Identity Identity Identity
    
    
    wat :: FileLookup -> anything
    wat = absurd . getConst . contents
    
    main :: IO ()
    main = do
      let q :: [Maybe (Either () [Double])]
          q = wat $ File { id' = 123 }
      print q
    

    The only compile-time problem this has is the warning you want to disable:

    Main.hs:26:17: warning: [-Wmissing-fields]
        • Fields of ‘File’ not initialised:
            md5Hash :: Const Void Text
            contents :: Const Void Text
            description :: Const Void Text
    

    Calling contents on a FileLookup is perfectly fine by the type-checker, so long as we can handle the resulting Const Void (which is easy to do, since we have absurd). So if your desired functionality existed, this program would compile warning-free and then blow up at runtime. with Main: Main.hs:26:17-34: Missing field in record construction contents.

    The way Void is expected to be used is more commonly to mark cases of a sum type that cannot happen. For example Either Void b is a type that can only contain values of the form Right (_ :: b); to have constructed a Left Void we would have first needed a value of type Void to apply Left to.2 Then either absurd is a way to handle such Either Void b values, relying on the assumption that either will never take its Left branch (and thus will never actually apply absurd to anything) because no such value will ever exist, and checking that this case is "impossible" by requiring it to have type Void (unlike handling an "impossible" case with something like error "will never happen", which will happily convert into a runtime bug when the types change so that the impossible case is now possible).

    It's less common to see it in a purely product type, like yours, since that would indicate that that FileCreate (etc) values will never exist, rather than indicating that certain of their fields will never be used.

    Thus I think it unlikely that there would be any specific support your idiom built into the compiler, in the form of treating the "uninitialised field" warning differently based on the type of the field. Void simply isn't designed to be used that way.

    In fact, the fact that it's Void rather than any other specific type isn't really doing anything. The only use you're getting out of it is that if someone accidentally tries to fill in the contents of a FileLookup they will almost certainly get a type error telling them about the mistake, since Const Void won't match the type that would normally go in the contents field (and the same thing if anyone tries to read such a field). But Const () isn't going to match their expected type either; nor would Const MyUnexportedType, nor even something arbitrary and dumb like Const [Maybe (Either () [Double])]. You aren't really getting any use out of the fact that Void is uninhabited by simply leaving the field uninitialised; you could leave a field of any other unlikely-to-be-useful type uninitialised and get almost exactly the same type safety.


    1 Of course Left undefined works perfectly well to construct a value of Either Void b that uses the Left constructor. We basically trust that there is a "gentlemen's agreement" not to do things like that though; it's generally hard to accidentally bypass the type checker in that way, and Void is specifically intended to support the style of programming where we try to ensure everything is total and invariants are checked by the compiler.


    So what do I think you should do?

    Maybe it's just the simplified example you've given here, but I also don't really see that you're getting "nice" syntax out of this pattern. FileLookup { id = 123 } looks nice, but it doesn't actually work that way; FileLookup is a type; record creation syntax is used with a constructor. So you actually have to use File { id = 123 }; nothing says that it's a lookup. A FileDelete would presumably also only need an id and thus would also look like File { id = 123 }.

    Furthermore the { id = 123 } bit only looks nice because there's a Num instance for Identity. There's an IsString instance too, so if you're using OverloadedStrings your Text fields also probably look nice. But anything past that will make you start writing things like { flag = Identity True }, and you'll still have to deal with adding and removing Identity wrappers if you want these fields to connect to anything that deals in plain Int and Text values, even if you can have invisible Identity wrappers with literals in your source code.

    Really the pattern synonym version (where the unused fields are based on () rather than Void) would be much nicer to use in practice. And creating a pattern synonym per operation per data type isn't much more boilerplate than creating a type synonym per operation per data type. Here's a quick example I knocked up:

    {-# LANGUAGE PatternSynonyms, OverloadedStrings, DuplicateRecordFields #-}
    
    import Control.Applicative
    import Data.Functor.Identity
    import Data.Text
    
    -- same as before
    data File' key generated value = File {
      id' :: key Int,
      md5Hash :: generated Text,
      contents :: value Text,
      description :: value Text
    }
    
    -- GHC couldn't figure out the constraints with a plain deriving clause on the
    -- data type; this isn't important, it's just so I could demo usage in main
    deriving instance (Show (key Int), Show (generated Text), Show (value Text))
      => Show (File' key generated value)
    
    -- Unnecessary, but a decent amount of boilerplate reduction, and more easily
    -- conveys the "disallowed" sense.
    type X = Const ()
    pattern X = Const ()
    
    -- Note that here the id' value is a real Int, not an Identity Int. The
    -- pattern can transparently apply/remove the Identity wrapper for us.
    -- It's entirely up to you whether you do that, but I sure would.
    -- (Using the real File constructor makes the Identity visible, of course)
    pattern FileLookup :: Int -> File' Identity X X
    pattern FileLookup { id' } = File (Identity id') X X X
    
    -- Note that the type signatures for the pattern synonyms are optional
    -- if you're averse to boilerplate; I've included them here for clarity
    pattern FileUpdate :: Int -> Maybe Text -> Maybe Text -> File' Identity X Maybe
    pattern FileUpdate { id', contents, description } = File (Identity id') X contents description
    
    main :: IO ()
    main = do
      print $ FileLookup { id' = 123 }
      print $ FileUpdate { id' = 123, contents = Just "foo", description = Nothing }
    

    Here's what this prints, so you can see it's filled in the

    File {id' = Identity 123, md5Hash = Const (), contents = Const (), description = Const ()}
    File {id' = Identity 123, md5Hash = Const (), contents = Just "foo", description = Nothing}
    

    And if you leave a field out, like FileUpdate { id' = 123, contents = Just "foo" }, then you of course get an uninitialised fields warning:

    Main.hs:36:11: warning: [-Wmissing-fields]
        • Fields of ‘FileUpdate’ not initialised:
            description :: Maybe Text
    

    One note:

    A big enough issue that I didn't want to bury it in comments in the code example: pattern synonyms with record syntax of course define their own fields and selector functions. So I had to turn on DuplicateRecordFields to allow this, and if you try to use id' as a selector you'll almost certainly get an ambiguity error.

    What I would probably really do is define the pattern synonyms in a separate module with DuplicateRecordFields and NoFieldSelectors, and then use DisambiguateRecordFields at the usage sites. Then you would only have to worry about qualifying them if you use record update syntax (not pattern matching or record creation) in a module where you both the real type and a pattern synonym (or two pattern synonyms) in scope.

    Alternatively it might be nice to NoFieldSelectors the real type as well, and only ever use explicit patterns. The pattern synonyms I've defined don't merely not use the unused fields, they actually enforce that the unused fields are Const (). If you consume these records (presumably in a module that's actually carrying the operations out) by writing patterns using File directly (leaving the unused fields unmatched) or calling the field selector functions, then someone could initialise all fields and use that record with any operation, with no type safety. But if the FileLookup etc patterns are used at the use sites as well as the creation sites, then that would be caught as an error. (Although if everything ends up completely separated, there's not much point in having a single record; separate data types for each pattern synonym would be simpler. Presumably there is some level where you handle these things generically without caring what kind of operation is in them)