thoughtbot / Argo

Functional JSON parsing library for Swift
https://thoughtbot.com
MIT License
3.49k stars 198 forks source link

Is there any way to decode an array of different types? #325

Closed maxschmeling closed 8 years ago

maxschmeling commented 8 years ago

I have JSON that is analogous to this:

{
  "animals": [
    { "type": "cat", "name": "kitty", "remainingLives": 4 },
    { "type": "dog", "name": "spot", "trained": false }
  ]
}

I would love to have an Animal type with a name property and then a subclass for Cat with a remainingLives property and Dog with a trained property.

It doesn't appear to be possible with Argo. Am I missing anything? If not, is this something that you think might fit into Argo? I would be happy to work on it with a little guidance. (I'm very new to Swift)

maxschmeling commented 8 years ago

I realized I can do something like this:

extension Animal: Decodable {
    static func decode(j: JSON) -> Decoded<Animal> {
        let dict = j.JSONObject() as! [String:AnyObject]
        let type = dict["type"] as! String

        if type == "cat" {
            return Cat.decode(j)
        } else if type == "dog" {
            return Dog.decode(j)
        }

        return curry(Animal.init)
            <^> j <| "name"
    }
}

Is this the most appropriate thing to do in this scenario?

maxschmeling commented 8 years ago

I guess this won't actually work because the Swift compiler can't convert Decoded<Cat> to Decoded<Animal>. I feel like I'm just missing something simple because of my inexperience with the language.

paulyoung commented 8 years ago

@maxschmeling I think you'd need to use type erasure to allow that.

This article by @rnapier might help: http://robnapier.net/erasure

maxschmeling commented 8 years ago

Thanks @paulyoung

gfontenot commented 8 years ago

I don't think you'll need to go as far as type erasure, instead you should be able to use <|> to flip between the different subclasses (kinda turning Animal into a class cluster). I might try something like this:

class Animal {
  let name: String

  init(name: String) {
    self.name = name
  }
}

extension Animal: Decodable {
  static func decode(j: JSON) -> Decoded<Animal> {
    return Cat.decode(j) <|> Dog.decode(j)
  }
}

final class Cat: Animal {
  let remainingLives: Int

  init(name: String, remainingLives: Int) {
    self. remainingLives = remainingLives
    super.init(name: name)
  }
}

extension Cat { // Note that I'm _not_ making Cat decodable
  static func decode(j: JSON) -> Decoded<Animal> {
    let cat = curry(self.init)
      <^> j <| "name"
      <*> j <| "remainingLives"

    return cat as Decoded<Animal> // This might not be needed, I might try just returning the expression above first
  }
}

final class Dog: Animal {
  let trained: Bool

  init(name: String, trained: Bool) {
    self.trained = trained
    super.init(name: name)
  }
}

extension Dog { // Note that I'm _not_ making Dog decodable
  static func decode(j: JSON) -> Decoded<Animal> {
    let dog = curry(self.init)
      <^> j <| "name"
      <*> j <| "trained"

    return dog as Decoded<Animal> // This might not be needed, I might try just returning the expression above first
  }
}

Since the types for the Cat and Dog initializers is different, this should be all you need. If there are overlapping animal subclasses, you could add a simple validator to the beginning of your subclass decode functions:

func validateType(expectedType: String, json: JSON) -> Bool {
  let type: String? = (json <| "type").value

  return type == expectedType
}

// later, in Dog.decode:

guard validateType("dog") else { return .typeMismatch("dog", actual: j) }
maxschmeling commented 8 years ago

@gfontenot maybe I'm doing something wrong, but the compiler says:

Cannot convert return expression of type 'Decoded<Dog>' to return type 'Decoded<Animal>'.

on return dog as Decoded<Animal>

maxschmeling commented 8 years ago

I'm also having trouble getting the type erasure working, but still working on it. Trying to wrap my head around how it would work in this scenario.

gfontenot commented 8 years ago

Sorry, I had spiked this code out here on GitHub. I was able to get this to compile by:

The resulting code (that compiles for me):

class Animal {
  let name: String

  init(name: String) {
    self.name = name
  }
}

extension Animal: Decodable {
  static func decode(j: JSON) -> Decoded<Animal> {
    return decodeCat(j) <|> decodeDog(j)
  }
}

final class Cat: Animal {
  let remainingLives: Int

  init(name: String, remainingLives: Int) {
    self.remainingLives = remainingLives
    super.init(name: name)
  }
}

func decodeCat(j: JSON) -> Decoded<Animal> {
  return curry(Cat.init)
    <^> j <| "name"
    <*> j <| "remainingLives"
}

final class Dog: Animal {
  let trained: Bool

  init(name: String, trained: Bool) {
    self.trained = trained
    super.init(name: name)
  }
}

func decodeDog(j: JSON) -> Decoded<Animal> {
  return curry(Dog.init)
    <^> j <| "name"
    <*> j <| "trained"
}
gfontenot commented 8 years ago

Type erasure really shouldn't be needed here, because we can use covariance to return Decoded<Cat> as Decoded<Animal>.

maxschmeling commented 8 years ago

@gfontenot the compiler errors were making me think that isn't possible. Working on attempting the latest code sample. I appreciate the help.

maxschmeling commented 8 years ago

I was able to convert this into my real-world code and it's compiling. Will do some testing to make sure it's all working, but it appears to do what I need.

I really appreciate the help.

gfontenot commented 8 years ago

@maxschmeling Glad it's working for you! Don't hesitate to re-open if you need more help or if you have any other questions.

maxschmeling commented 8 years ago

@gfontenot just wanted to follow up and say that once I added in the typeMismatch guard, everything worked perfectly. Thanks again for the help.

hunaid-hassan-confiz commented 8 years ago

how can I go about implementing this with a fairly large model? you know, the type where we have to split curry in intermediate vars to avoid "Expression too complex" errors. This is my model

func decodeCar(j: JSON) -> Decoded<AdDetailModel> {

    let a = curry(CarAdDetailModel.init)
        <^> j <| "ad_id"
        <*> j <| "phone"
        <*> j <| "city_name"
        <*> j <| "city_area"
        <*> j <| "seller_comments"
        <*> j <| "price"
        <*> j <| "url_slug"
        <*> j <|| "pictures"
        <*> j <| "is_featured"
        <*> j <| "ad_listing_id"
    return a
        <*> (j <| "last_updated" >>- toNSDate(""))
        <*> j <| "featured_request"
        <*> j <| "ad_saved"
        <*> j <| "make"
        <*> j <| "model"
        <*> j <| "version"
}

There is a return type mismatch compiler error

gfontenot commented 8 years ago

@hunaid-hassan-confiz I'm not sure what you're asking. Can I see your model? Also probably the implementation of toNSDate?

hunaid-hassan-confiz commented 8 years ago

Nevermind. My model is unusually large. 25+ properties. I was having trouble getting rid of the compiler error "Expression too complex", which I managed to somehow but then having to write an initialiser for every class explicitly was too much of work also Argo hits its limit at 15+ properties after which the compiler error just doesn't go away whatever you do. So I am planning to move to alternative. I haven't decided which though

gfontenot commented 8 years ago

Argo hits its limit at 15+ properties after which the compiler error just doesn't go away whatever you do.

To be clear, Argo has zero limits on the number of properties. I believe what you're hitting is the limitations (imposed by the compiler) on the number of curry instances we can provide via Curry.framework. We recently updated Curry.framework to be able to handle up to 21 arguments, although that won't help you here given that your model has 25+.

TBH, I constantly question the design of models that have that many properties. I don't know the domain you're working in at all, but I'd highly suspect that you could refactor into multiple objects which might improve the overall design as well as make it easier to use with Argo.

hunaid-hassan-confiz commented 8 years ago

You are right. I am not very happy with the current design of the models. A lot of those properties can be grouped together in smaller structs but its a legacy system I have to work with. It indeed is a compiler limitation. Hope it goes away soon. Thanks for the time though :)