1

Often, when programming, you'll have different degrees of information to you in different contexts.

For example, a web server may have two routes, which recieve information about a Person, one of these routes receives a Person with a Name and an id, and the other just receives a Name.

We want to write code that looks something like this, but probably more complicated:

def lookupPersonAge(person: Person): Int

// this doesn't receive a name
def route1(person: Person): Response = {
    complete(lookupPersonAge(person))
}

// this does receive a name
def route2(person: NamedPerson): Response = {
    complete(s"${person.name} is ${lookupPersonAge(person)} years old")
}

It's tempting to model Person as an interface, so that we can write generic code, that's usable between the two routes:

trait Person {
    def id: Int
}

case class PersonID(id: Int) extends Person

case class NamedPerson(id: Int, name: String) extends Person

The problem with this representation, is that if we want to write code against the generic Person interface, we loose a lot of useful things, such as pattern matching, and the auto generated copy method. This also looks confusingly similar to an Algebraic Data Type, despite the fact that it shouldn't be used like one.

Because of this, it's appealing to model NamedPerson using composition instead, for example

case class PersonId(id: Int)
case class NamedPerson(person: Person, name: String)

This is unwieldy, as it requires you to "lift" any methods written in terms of Person, in order to apply them to NamedPerson, in this case, we'd have to write route2 as:

def route2(person: NamedPerson): Response = {
    complete(s"${person.name} is ${lookupPersonAge(person.person)} years old")
}

It also scales poorly, as different combinations of fields are added, or if either type of Person may appear nested within another object.

For completeness, we could also model this using an Option:

case class Person(id: Int, name: Option[String])

This isn't appealing, because we do know whether or not the name is going to be present, we want to write code that's generic to whether or not it is. And this representation would lead us to write the following code, which requires us to use an unsafe call to get

def route2(person: NamedPerson): Response = {
    complete(s"${person.name.get} is ${lookupPersonAge(person)} years old")
}

I feel like what I'm missing here is some form of structural typing, with that said, I'm sure I'm missing a nicer way to solve this kind of problem. What's an effective way to resolve this issue?

2
  • This is getting downvoted, which may be fair, but I'd be open to constructive feedback so I can improve the question?
    – Joe
    Commented Mar 17, 2020 at 15:13
  • 2
    i really wouldn’t take it personally. I’m not super active here, but I browse almost daily and almost every question gets downvoted—even those with 2-3 highly upvoted answers. I upvoted—I think it’s a fantastic question about strong, statically typed languages like scala. In a dynamic language like lisp, there are solutions that just aren’t available to you because you have to satisfy the type checker (which I still think is good thing!). Again, +1 here Commented Mar 18, 2020 at 2:02

1 Answer 1

1

Scala does have structural types, but they incur a runtime penalty:

def lookupPersonAge(person: {val id: Int}): Int

There's also the typeclass route, but it requires more boilerplate to define the instances:

def lookupPersonAge[A](person: A)(implicit i: HasId[A]): Int

Another nice option in some situations is to make Named the trait:

trait Named {
    def name: String
}

case class Person(id: Int)

def route2(person: Person with Named): Response = {
    complete(s"${person.name} is ${lookupPersonAge(person)} years old")
}

This sort of generic programming is also what the shapeless library and related libraries like henkan are all about.

Those all have their uses and trade offs, but honestly, although I understand your question is simplified for illustration, in the vast majority of cases you're probably overthinking it. You're trying to codify a relationship between the types, when in reality, you just need to extract an id, which can be done with:

def lookupPersonAge(id: Int): Int

And called with:

def route1(person: Person): Response = {
    complete(lookupPersonAge(person.id))
}

def route2(person: NamedPerson): Response = {
    complete(s"${person.name} is ${lookupPersonAge(person.id)} years old")
}

Does lookupPersonAge really need features like pattern matching and copy methods, or does it just need the id? We don't want to artificially depend on things we don't need. So start simple and move to one of the more complex solutions if and when you actually need it. The simpler solutions also tend to be the most reusable.

You're suffering from choice here, which is a nice problem to have. No choice is going to be ideal in all situations.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.