Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Codable Conformance with Erased Types?

I'm trying to write a generic function to parse several different data types.

Originally this method only worked for Codable types, so its generic type was constrained with <T: Codable> and all was well. Now though, I am attempting to expand it to check if the return type is Codable, and parse the data accordingly based on that check

func parse<T>(from data: Data) throws -> T? {

    switch T.self {
    case is Codable:

        // convince the compiler that T is Codable

        return try? JSONDecoder().decode(T.self, from: data)

    case is [String: Any].Type:

        return try JSONSerialization.jsonObject(with: data, options: []) as? T

    default:
        return nil
    }

}

So you can see that the type-checking works fine, but I'm stuck on getting JSONDecoder().decode(:) to accept T as a Codable type once I've checked that it is. The above code doesn't compile, with the errors

Cannot convert value of type 'T' (generic parameter of instance method 'parse(from:)') to expected argument type 'T' (generic parameter of instance method 'decode(_:from:)') and In argument type 'T.Type', 'T' does not conform to expected type 'Decodable'

I've tried a number of casting techniques, like let decodableT: <T & Decodable> = T.self etc., but all have failed-- usually based on the fact that Decodable is a protocol and T is a concrete type.

Is it possible to (conditionally) reintroduce protocol conformance to an erased type like this? I'd appreciate any ideas you have, either for solving this approach or for similar generic-parsing approaches that might be more successful here.

EDIT: A Complication

@vadian helpfully suggested creating two parse(:) methods with different type constraints, to handle all cases with one signature. That's a great solution in many cases, and if you're stumbling across this question later it may very well solve your conundrum.

Unfortunately, in this only works if the type is known at the time that parse(:) is invoked-- and in my application this method is called by another generic method, meaning that the type is already erased and the compiler can't pick a correctly-constrained parse(:) implementation.

So, to clarify the crux of this question: is it possible to conditionally/optionally add type information (e.g. protocol conformance) back to an erased type? In other words, once a type has become <T> is there any way to cast it to <T: Decodable>?

like image 703
Renaissance8905 Avatar asked Oct 17 '22 06:10

Renaissance8905


1 Answers

You can conditionally cast T.self to Decodable.Type in order to get a metatype that describes an underlying Decodable conforming type:

  switch T.self {
  case let decodableType as Decodable.Type:

However, if we try to pass decodableType to JSONDecoder, we have a problem:

// error: Cannot invoke 'decode' with an argument list of type
// '(Decodable.Type, from: Data)'
return try? JSONDecoder().decode(decodableType, from: data)

This doesn't work because decode(_:from:) has a generic placeholder T : Decodable:

func decode<T : Decodable>(_ type: T.Type, from data: Data) throws -> T 

and we cannot satisfy T.Type with Decodable.Type because Decodable doesn't conform to itself.

As explored in the above linked Q&A, one workaround for this issue is to open the Decodable.Type value in order to dig out the underlying concrete type that conforms to Decodable – which we can then use to satisfy T.

This can be done with a protocol extension:

extension Decodable {
  static func openedJSONDecode(
    using decoder: JSONDecoder, from data: Data
  ) throws -> Self {
    return try decoder.decode(self, from: data)
  }
}

Which we can then call as:

return try? (decodableType.openedJSONDecode(using: JSONDecoder(), from: data) as! T)

You'll note that we've had to insert a force cast to T. This is due to the fact that by casting T.self to Decodable.Type we erased the fact that the metatype also describes the type T. Therefore we need to force cast in order to get this information back.

All in all, you'll want your function to look something like this:

func jsonDecode<T>(_ metatype: T.Type, from data: Data) throws -> T {
  switch metatype {
  case let codableType as Decodable.Type:
    let decoded = try codableType.openedJSONDecode(
      using: JSONDecoder(), from: data
    )

    // The force cast `as! T` is guaranteed to always succeed due to the fact
    // that we're decoding using `metatype: T.Type`. The cast to
    // `Decodable.Type` unfortunately erases this information.
    return decoded as! T

  case is [String: Any].Type, is [Any].Type:
    let rawDecoded = try JSONSerialization.jsonObject(with: data, options: [])
    guard let decoded = rawDecoded as? T else {
      throw DecodingError.typeMismatch(
        type(of: rawDecoded), .init(codingPath: [], debugDescription: """
        Expected to decode \(metatype), found \(type(of: rawDecoded)) instead.
        """)
      )
    }
    return decoded

  default:
    fatalError("\(metatype) is not Decodable nor [String: Any] nor [Any]")
  }
}

I have made a couple of other changes:

  • Changed the return type from T? to T. In general, you either want to handle errors by having the function return an optional or by having it throw – it can be quite confusing for the caller to handle both.

  • Added an explicit parameter for T.Type. This avoids relying on the caller to use return type inference in order to satisfy T, which IMO is a similar pattern to overloading by return type which is discouraged by the API design guidelines.

  • Made the default: case fatalError as it should probably be a programmer error to supply a non-decodable type.

like image 165
Hamish Avatar answered Oct 30 '22 13:10

Hamish