haskellpath

read/write a 'well-typed' path to a file


Using the package path for 'well-typed paths' in an application which writes and reads records containing filepath data. I miss a function to write the typed file path information in a record to a file and read it from the file.

Transforming Path a b types to strings with toFilePath to write and to read requires a conversion and managing the type information (Rel vs Abs, File vs Dir) separately, which is error prone.

I experimented with typeable but was not able to find a solution, which would allow me to include a typed Path a b in a record and read and write this record to/from a file. What solutions are possible?


Solution

  • I suggest taking a step back. As a general rule, you don't need to store type information as part of the serialization because the correct type information will already be available at the point of deserialization.

    I mean, you MIGHT be trying to serialize a Path in isolation, but normally it would be part of a larger data structure, say a configuration structure:

    data Config = Config { gamedir :: Path Abs Dir
                         , mods :: [Path Rel Dir]
                         , saves :: [Path Rel File] }
    
    ...
    cfg <- Config <$> parseAbsDir "/home/john/SuperPong"
                  <*> mapM parseRelDir ["paddleSkins", "multiplayer"]
                  <*> mapM parseRelFile ["game1.sav", "game2.sav"]
    

    If you serialize cfg using the existing ToJSON instances, you get:

    {"gamedir":"/home/john/SuperPong/","mods":["paddleSkins/","multiplayer/"],"saves":
    ["game1.sav","game2.sav"]}
    

    This lacks type information, but none is needed, because what are you going to do with this string except deserialize it back to a Config?

    let cfg = decode txt :: Maybe Config
    print cfg
    

    This yields a well typed Config whose gamedir field is a well typed Path Abs Dir and so on.

    If you want your Config structure to allow different Path types for a particular field, say a gamedir field that can either be absolute or relative to the user's home directory, the most natural way to accomplish this is via a sum type:

    data Config = Config { gamedir :: GameDir
                                      ^^^^^^^
                         , mods :: [Path Rel Dir]
                         , saves :: [Path Rel File] }
    
    data GameDir = AbsGameDir (Path Abs Dir) | HomeGameDir (Path Rel Dir)
    

    If you serialize such a Config using, say, the ToJSON instance, it'll include a tag for the constructor that will properly restore a Config with either an AbsGameDir or a HomeGameDir, as appropriate.

    If you are imagining that you would want to make Config polymorphic:

    data Config b = Config { gamedir :: Path b Dir
                ^                            ^
                           , mods :: [Path Rel Dir]
                           , saves :: [Path Rel File] }
    

    and serialize that, well, that's a pretty gnarly design even setting aside the serialization/deserialization challenges (class HasGameDir, anyone?).

    However, if you genuinely find yourself in a situation where you want to serialize and deserialize a Path b t polymorphically without knowing b and/or t, then you can't do much better than defining the sum type:

    data SomePath = AbsDir (Path Abs Dir) | AbsFile (Path Abs File)
                  | RelDir (Path Rel Dir) | RelFile (Path Rel File)
    

    and serializing that. Any self-respecting Haskell serialization scheme will handle serializing SomePath properly with some kind of constructor-based tagging which, in turn, will guarantee type safety.

    Finally, if your goal is somehow to ensure that the serialization itself is type safe in the sense that attempts to deserialize to the "wrong type" will fail, well, this seems like the wrong level of abstraction. If you anticipate serializing different types to the same place (e.g., serializing different, backward compatible versions of a Config structure to a configuration file), you should be using some header metadata or a version tag to identify the correct type variant to deserialize.