Struggling to forget
haskellPublished on January 3, 2020
In this post I show a use case and a way to implement conditional fixing of existentially quantified variables by universally quantified variables.
Motivation
Imagine we want to write a library for working with typed file paths.
path
is an example of such a library, but we’ll be aiming for
something more flexible. One flaw of path
is that it forces the users to
know too much about the paths:
-- | Path of some base and type.
--
-- The type variables are:
--
-- * @b@ — base, the base location of the path; absolute or relative.
-- * @t@ — type, whether file or directory.
newtype Path b t = Path FilePath
deriving (Data, Typeable, Generic)
To create a value of the type Path
one has to choose what to expect, or in
other words one has to fix b
and t
. Correspondingly, there are four
conversion functions1:
parseAbsDir :: MonadThrow m => FilePath -> m (Path Abs Dir)
parseRelDir :: MonadThrow m => FilePath -> m (Path Rel Dir)
parseAbsFile :: MonadThrow m => FilePath -> m (Path Abs File)
parseRelFile :: MonadThrow m => FilePath -> m (Path Rel File)
There are no parseSomeDir
or parseRelSome
functions. This makes the
users of path
to prefer absolute paths and resolve relative paths early in
the program by using IO
-enabled parsing functions instead. It works fine
most of the time, but sometimes we may want to parse purely yet without
knowing whether our path is absolute or relative.
The type of our path could be this:
data Platform = Posix | Win
data Base = Abs | Rel
data Type = Dir | File
data Path (p :: Platform) (b :: Base) (t :: Type) where
PosixAbsDir :: FilePath -> Path 'Posix 'Abs 'Dir
PosixAbsFile :: FilePath -> Path 'Posix 'Abs 'File
PosixRelDir :: FilePath -> Path 'Posix 'Rel 'Dir
PosixRelFile :: FilePath -> Path 'Posix 'Rel 'File
WinAbsDir :: FilePath -> Path 'Win 'Abs 'Dir
WinAbsFile :: FilePath -> Path 'Win 'Abs 'File
WinRelDir :: FilePath -> Path 'Win 'Rel 'Dir
WinRelFile :: FilePath -> Path 'Win 'Rel 'File
Here I add the platform index because a good library should make distinction between Posix and Windows paths. Windows paths have the drive letter for example, while Posix paths do not, so they are not the same thing. Indeed, we should be able to even mix both in the same program.
Another reason I added an extra type index is to make the combinatorial explosion really bad. If we want to be able to make some indices existential while leaving the others fixed, we just won’t be able to use existential wrappers because there are too many possible combinations:
data PathSomePlatform b t = <…>
data PathSomeBase p t = <…>
<…>
data PathSomePlatformSomeBase t = <…> -- both platform and base are existential
<…>
In other words, we want a smart constructor which can fix arbitrary sub-set
of the type indices, that is, convince the type system that if we know that
we expect an absolute path then b ~ 'Abs
in the returned Path
while
leaving things that we do not know existential. Those are to be discovered
later by case-analysis.
Solution
If GHC had proper existential quantification we wouldn’t need CPS, but right now it is the only way to get the required flexibility on the type level:
mk ::
MonadThrow m =>
-- ...
FilePath ->
(forall p b t. Path p b t -> m r) ->
m r
It wasn’t obvious to me at first how to achieve fixing p
b
and t
only
sometimes while leaving them existential in other cases. We spent some time
discussing it with Richard Eisenberg on several occasions, and yet concluded
that it was probably impossible.
Later though, it occured to me that conditional constraining may have something to do with constraints:
data ((x :: k) `Or` (y :: k)) (c :: k -> Constraint) where
Any :: (x `Or` y) Unconstrained
First :: (x `Or` y) ((~) x)
Second :: (x `Or` y) ((~) y)
class Unconstrained (x :: k)
instance Unconstrained x
mk ::
MonadThrow m =>
('Posix `Or` 'Win) pc ->
('Abs `Or` 'Rel) bc ->
('Dir `Or` 'File) tc ->
FilePath ->
(forall p b t. (pc p, bc b, tc t) => Path p b t -> m r) ->
m r
By defining a few values with more friendly names we can then achieve nice end-user experience:
any :: (x `Or` y) Unconstrained
any = Any
posix :: ('Posix `Or` 'Win) ((~) 'Posix)
posix = First
win :: ('Posix `Or` 'Win) ((~) 'Win)
win = Second
abs :: ('Abs `Or` 'Rel) ((~) 'Abs)
abs = First
rel :: ('Abs `Or` 'Rel) ((~) 'Rel)
rel = Second
dir :: ('Dir `Or` 'File) ((~) 'Dir)
dir = First
file :: ('Dir `Or` 'File) ((~) 'File)
file = Second
In the case of any
, the corresponding type index will be fixed anyway
during parsing, yet it won’t be immediately known at the type level.
Case analysis on the Path p b t
type
The general idea is that the types are not constrained unnecessarily but can be discovered when needed by performing case-analysis. Consider a pattern like this:
pattern IsPosix :: Path 'Posix b t -> Path p b t
pattern IsPosix path <- (isPosix -> Just path)
isPosix :: Path p b t -> Maybe (Path 'Posix b t)
isPosix = \case
PosixAbsDir path -> Just (PosixAbsDir path)
PosixAbsFile path -> Just (PosixAbsFile path)
PosixRelDir path -> Just (PosixRelDir path)
PosixRelFile path -> Just (PosixRelFile path)
_ -> Nothing
By using it the users could establish that p ~ 'Posix
in one branch of
execution while handing the opposite case in the other branch. This does the
same thing that we do routinely with sum types, but for type-level indices.
In a way this is similar to using bare GADTs except the correspondence
between types and data constructors is less precise and you do not discover
more than necessary unless you want to—patterns like this can also be
nested!
A new library?
While the main difficulty is now resolved it is not clear how to handle the
any
case in practice for something like Dir
vs File
. Indeed, a
trailing slash may guarantee that you have a directory path, but if it is
not there and the user doesn’t say what he/she wants, it is hard to guess
what you have—a directory or a file.
Less seriously, how can I release the library if I’m now quoted as a strong proponent of using simple Haskell? People will be confused.
- There are more, but they are just variations of these, e.g. in the form of quasi-quotes or TH helpers. ↩