Search code examples
haskelltypeclassalgebraic-data-types

Sum Types vs. Type Classes vs. Records


Which of the following three approaches would be the most idiomatic to implement some kind of rarity for items in an RPG?

The sum type approach "feels" like it's the right one since rarities seem like a "closed set" (or immutable? idk if that's the right word). But if this were defined in a library then I would not be able to add any more rarities which seems odd.

data Rarity = Rarity { symbol :: String, value :: Int }

common    = Rarity "C" 1
rare      = Rarity "R" 2
legendary = Rarity "L" 3
data Rarity = Common | Rare | Legendary

symbol :: Rarity -> String
symbol Common    = "C"
symbol Rare      = "R"
symbol Legendary = "L"

value :: Rarity -> Int
value Common    = 1
value Rare      = 2
value Legendary = 3
class Rarity r where
  symbol :: r -> String
  value :: r -> Int

data Common = Common
instance Rarity Common where
  symbol _ = "C"
  value _ = 1

data Rare = Rare
instance Rarity Rare where
  symbol _ = "R"
  value _ = 2

data Legendary = Legendary
instance Rarity Legendary where
  symbol _ = "L"
  value _ = 3

Solution

  • The type class approach you have shown is not very ergonomic in practice.

    Presumably you want to have items with a rarity attribute. But what type should the rarity field be? Common, Rare, and Legendary are all separate types, so you can't just have data Item = Item { ..., rarity :: Rarity } (Not to mention the supposed additional rarity levels added by the client program, if this is in a library).

    You can have data Item r = Item { ..., rarity :: r }, but now the type of a list (or any other generic collection) of items has to specify what single rarity level all the items in it have (e.g. [Item Common]). That isn't how you want to use rarities in practice (since e.g. a player's inventory can contain items of different rarities!).

    You can work around that by using an existential type:

    {-# LANGUAGE GADTs #-}
    
    data Item
      where Item :: Rarity r =>
              { ...
              , rarity :: r
              }
              -> Item
    

    But now this is basically isomorphic to your first proposal (with data Rarity = Rarity { symbol :: String, value :: Int }), only way more complicated to use. Since you can't do anything with a value of an unknown type that is constrained to be in the Rarity type class other than call Rarity methods, and all those do is get you a String and an Int, you might has well have just used a record of a String and an Int to begin with, and saved all the extensions and boilerplate.

    So now we're down to the first two versions: record or sum type.

    A potential advantage of the record version is that you (or any client code) can come up with arbitrary new rarity levels. A big potential disadvantage is that you (or any client code) can come up with arbitrary new rarity levels, without any guarantee of consistency with rarity levels used in other items.

    Whether that's a feature or a problem really depends on how you intend to process the String and Int that make up a rarity level. If you can truly write engine code in your RPG library that handles those completely agnosticaly of any properties between them, then the record is the right way to go. But my instinct is for RPG rarity levels, you won't do that. You'll rely on an ordering, you'll rely on a given String rarity code corresponding to the same Int value each time you see it, etc etc.

    I would go for the static sum type when writing a individual RPG. It just fits my conception of rarity levels better (as you say, within one game it normally should be a closed set, not arbitrarily extensible).

    For writing an RPG library, I would use a type class, but differently than you used it.

    class Rarity r where
      symbol :: r -> String
      value :: r -> Int
    
    data GameOneRarity = Common | Rare | Legendary
    
    instance Rarity GameOneRarity
      where symbol Common = "C"
            symbol Rare = "R"
            symbol Legendary = "L"
    
            value Common = 1
            value Rare = 2
            value Legendary = 3
    
    
    data GameTwoRarity = Boring | Cool | Awesome
    
    instance Rarity GameTwoRarity
      where symbol Boring = "B"
            symbol Cool = "C"
            symbol Awesome = "A"
    
            value Boring = 100
            value Cool = 1000
            value Awesome = 10000000
    

    I don't have a separate type for each new rarity level, I have a separate type for each rarity scheme. The idea would be for each game to define its own rarity type. An individual set of rarities is not extensible, but the library code can handle arbitrary schemes with whatever rarity levels the game designer wants (so long as they can implement symbol and value for them).

    The inventory system I write generically in the RPG library can use types like [Item r] to ensure that all of the items it considers together use the same rarity system (avoiding the possibility of having to answer non-sensible questions like whether a Legendary item from one game is more or less valuable than a Cool item from a different game) but each item can have its own rarity level within that system.