Subtying using Phantom Types and Extendable Records

Here’s a neat little trick I came up with (not sure if anyone else has done this yet.)

Let’s say you want to buy a pet, and you currently can’t decide.

import PetShop.Cat exposing (Cat)
import PetShop.Dog exposing (Dog)
import PetShop.Fish exposing (Fish)

You know that no matter which animal you choose, you will need to feed it. Also, cats and dogs may go outside - a fish should probably stay it its tank.

feed : Animal a -> Animal a

goOutside : DogOrCat a -> DogOrCat a

buyCat : Cat -> MyCat

buyDog : Dog -> MyDog

buyFish : Fish -> MyFish

We now want to define Animal and DogOrCat such that the following code-snippets compile:

PetShop.cat
  |> buyCat 
  |> feed 
  |> goOutside

PetShop.dog
  |> buyDog
  |> feed
  |> goOutside

but the same will not work for a fish

PetShop.fish
  |> buyFish
  |> feed
  |> goOutside -->CompilerError

Solution

type Animal animal
  = Animal
    { cat : Cat
    , dog : Dog
    , fish : Fish
    }

type alias IsCat
  = { isCat : ()
    , isDogOrCat : ()
    }

type alias IsDog
  = { isDog : ()
    , isDogOrCat : ()
    }

type alias IsFish
  = { isFish : () }

type alias MyCat =
  Animal IsCat

type alias MyDog =
  Animal IsDog

type alias MyFish =
  Animal IsFish

type alias DogOrCat a =
  Animal { a | isDogOrCat : () }

Further Ideas

If you are willing to own a Schrödingers Maybe Cat, you could implement Animal like this instead:

type Animal animal
  = Animal
    { cat : Maybe Cat
    , dog : Maybe Dog
    , fish : Maybe Fish
    }

buyCat : Cat -> MyCat
buyCat cat =
   Animal
    { cat = cat
    , dog = Nothing
    , fish = Nothing
    }

With this implementation you can now group all your pets in a single list

toAnimal : Animal a -> Animal ()
toAnimal (Animal animal) = Animal animal

toMyCat : Animal a -> Maybe MyCat
toMyCat (Animal animal) =
  if animal.cat /= Nothing then
    Just (Animal animal)
  else
    Nothing

myAnimals : List (Animal ())
myAnimals =
  [ PetStore.cat |> buyCat |> toAnimal
  , PetStore.dog |> buyDog |> toAnimal
  , PetStore.fish |> buyFish |> toAnimal
  ]

myCats : List MyCat
myCats =
  myAnimals
  |> List.filterMap toMyCat

So that’s it. I Hope you liked this little post :wink:

3 Likes

I love this pattern, I use this idea in elm-review, and made a talk at the beginning of the month about it (video: The phantom builder pattern - Jeroen Engels - YouTube, slides: Log in – Slides). It’s not about sub-typing, but I think you’ll see the similarities.

In practicte, I don’t think subtyping with this pattern is all that nice, especially if you want to have them stored in a List. As you show, if you want to store it in a list, then you need to remove all non-shared traits and start dealing with the possibility of the animal not being the type you wish it was or being able to what you wished it to do.

So I think the pattern is very valuable, but only until the point where you wish to make them align (though as you showed, you can get them back to individual/differentiated animals through filtering).

In my head, I imagine this pattern to have a “schema”/“blueprint” part, and then a “usable type” part. In this case the schema part would be going to the petstore, choosing your pet, and buying it. You basically define what you want to have. The usable type is the pet that you’ve bought and can go outside with for instance.

The schema part is where “subtyping” really shines because you can have a lot of common properties and operations. But in the usable part, I don’t know if it is as much. It really depends on your use-case and whether you want to remove the differences (put them in a list).


I think that for the sake of better error messages, I would not add all the type aliases (for IsFish, IsCat, MyCat`)), as they will hide the parts that don’t match.


(Could you add the types/definitions for the original Animal, and also for Cat, Petshop.cat, etc.? Seems like a missing piece in your explanation)

2 Likes

First of all, I assumed that PetShop is an external package and Cat is opaque. That’s why i did not provide any definition.

For the definition of buyCat I was thinking of something like

buyCat : Cat -> MyCat
buyCat cat =
  Animal
    { cat = cat
    , dog = PetShop.dog --or any other default value
    , fish = PetShop.fish --or any other default value
    }

For this example the definition might feel a bit wierd (if the pet shop only has a single cat, then buyCat would not need an argument.)

So maybe we need to extend the example and say that the pet shop has cats in different colors and PetShop.cat just represents some default cat.

PetShop.cat
  |> PetShop.withBlackFur
  |> buyCat

Note that this method does not work if I don’t have a default value.

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.