SE-0396: Conform Never to Codable

Hello Swift community,

The review of SE-0396: "Conform Never to Codable" begins now and runs through May 3, 2023.

Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to the review manager. When emailing the review manager directly, please keep the proposal link at the top of the message.

What goes into a review?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:

  • What is your evaluation of the proposal?
  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

More information about the Swift evolution process is available at

https://github.com/apple/swift-evolution/blob/main/process.md

Thank you,

ā€”Tony Allevato
Review Manager

26 Likes
  • What is your evaluation of the proposal?

Yes. Never should conform to every protocol it can get away with.

  • Is the problem being addressed significant enough to warrant a change to Swift?

Yes. The improvement is small to moderate, but the change is trivial, and more importantly it is consistent and extensible to other cases.

  • Does this proposal fit well with the feel and direction of Swift?

Yes. It has been a long time weirdness that it wasn't true.

  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

Years of thinking about it, and joy that someone finally actually took the trouble to write it as a proposal.

16 Likes

Adopting this conformance would comport with the core team's previously articulated approach with respect to Never conformances, so overall this seems to be fine.

A specific question with respect to the proposed detailed design I have has to do with the specific DecodingError. Why was dataCorrupted chosen over, say, typeMismatch?ā€”it would seem to me that if there's any value found at all while decoding, it would be more on the nose to say that it's not a value of type Never rather than that it's nebulously corrupted, since it's fundamental to Never that it is an uninhabited type (i.e., that there are no values of that type).

16 Likes

This is a straightforward improvement with no downsides I can foresee. Yes, please!

10 Likes

Agree this is a nice improvement!

I donā€™t know, IMO dataCorrupted does a better job communicating that something must have gone seriously wrong for you to decode a Neverā€”the problem isnā€™t really that you found something of a different type when trying to decode a Never, itā€™s that you reached the point of decoding a Never at all. I feel like typeMismatch isā€¦ underselling a bit.

10 Likes

Yes please! In fact, I think Never should automatically conform to any protocol that doesn't declare constructors (like init or static func that return Self), and in the case of Decodable I agree with the custom implementation of the init.

Trying to decode a Never throwing dataCorrupted seems backwards to me. It is not the data that may be corrupted but the logic attempting to do such decoding. The data may be totally fine {"hi": 1} but code may be doing something weird and wrongly attempt to decode it as Never -- it is a typeMismatch to me is a more logical error to throw, or something like "cannot decode Never" that is very specific about what is happening.

One one more small step towards Never behaving like real bottom type huh :wink: I'd love a real bottom type, but this is a good improvement -- I'm +1 for the proposal.

9 Likes

I agree that decoding Never means a logic error, not a data error. My (possibly controversial) opinion is that it should call fatalError(). That is how you instantiate a Never.

Are there any ways you could get into this situation through bad data and not programmer error?

That said, I appreciate that folks really hate crashing the program when a throws is available, and Iā€™m fine with it throwing.

6 Likes

I was thinking about this last night, and also somewhat split on whether getting to a point where decoding Never is a logic error or a data error ā€” and I while I do think can be either, I think that throwing an error is the right way to go here, for practical reasons.

Within the original context of the pitch, consider a type

enum E<A: Codable, B: Codable, ..., Z: Codable>: Codable {
    case a(A), b(B), ..., z(Z)
}

(e.g., Either, or a similar generalization of it; not all cases need to have an associated value, just one where you might want to use Never)

I can easily imagine that code-wise, it may be desirable to pass around E<..., Never, ...> when you expect one of the cases to never be present under normal circumstances, and for well-formed data to never contain such a case. It's not a logic error, necessarily, to express this.

If we were to fatalError:

  1. Adversarially: it would be trivial to pass in a case which parses successfully, and causes the decode of a Never, leading to an unrecoverable crash. The type decoding E wouldn't easily be able to prevent parsing and decoding this case, nor would E be responsible for checking for Never and preventing decoding it. (I can imagine the type containing E to also be generic over the type, meaning that you may, from some very far away point, need to deeply inspect the data just to ensure a specific case isn't present)
  2. Less adversarially: data changes over time, and what's disallowed now may not be in the future. An app may choose to use E<..., Never, ...> today, but a future version of the app may instantiate that case ā€” and if so, its encoded data may validly encode this case. If we tried opening this data up with an older version of the app, we'd crash with no recourse

I think that in both cases, it's better to throw an error so code can recover. That being said, maybe it's worth threading the needle by introducing a new DecodingError value (possibly underscored?), so that existing code at least won't be poised to catch it by name, and ideally it'll bubble up to the top, uncaught...

9 Likes

Exactly. This question has bugged me as well, because I initially thought that a failed attempt at decoding Never could only be fixed at the code level (just don't use Never), and that only a code change could fix the problem. That's the very definition of a programmer error, and a good justification for a fatal error.

But there's no reason to prevent one from decoding Either<Int, Never>, when one expects the decoded data to contain an Int left value.

If the data contains something else, such as a String left value, or a Int right value, it is a runtime error due to invalid input data, not a programmer error.

5 Likes

On the debate about which error to throw:

A possible approach would be to consider that from an encoding/decoding perspective the type Result<Int, Never> does not have the failure case, meaning that it should behave identically to whatever we do in the below case:

/// Imagine we have this enum
enum AorB: Codable {
    case a (Bool)
    case b (Int)
}

/// We make this value
let value = AorB.b(7)

/// We also have this enum
enum A: Codable {
    case a (Bool)
}

/// If we encode our value and then try
/// to decode it as `A` we get an error
try value
    .asJSONData()
    .jsonDecoded(as: A.self)

/// Maybe we should expect
/// the same sort of error here
try Result<Bool, ErrorMessage>
    .failure(ā€œAn error occurredā€.asErrorMessage())
    .asJSONData()
    .jsonDecoded(as: Result<Bool, Never>.self)

Am I correct in thinking that to have this behavior we would need modify the synthesized Codable conformance for enums to handle Never in a special way?

Also, IIUC regardless of whether this is considered sound reasoning we will still have to decide on an error to throw when decoding to Never (because the below is always possible):

try myJSONData.jsonDecoded(as: Never.self).

Count me in with this crowd. For example, choosing to crash a mobile app is not a technical issue, but rather a user experience one, and crashing will always result in a bad user experience. It's often said that one should "crash early", but that's only valid in a flow where there's no alternative: in other words, if you absolutely cannot avoid crashing, you should crash as early as possible, but you should still avoid crashing in general.

So, I definitely agree with the proposal's approach on this.

2 Likes

Yes, that's correct.

  1. For non-generic enums, the compiler can statically know whether a case can be written off during synthesis
  2. For generic enums, the types would need to be inspected at runtime, since the generics can be instantiated externally; synthesis would need to be updated in the compiler to check all generic types. This may come at an unfortunate performance cost for a very rare scenario
It does get a bit trickier, if you want to take this idea further:

Never is not the only non-instantiable type, but we don't currently to my knowledge have a formalized concept of "non-instantiable", either in the compiler or at runtime. For example:

enum MyNever {
    private init() { fatalError() }
}

struct MyNonInstantiableStruct {
    // Non-instantiable, because of either type:
    let never1: Never
    let never2: MyNever
}

The compiler knows about Never, but it doesn't know about MyNonInstantiableStruct ā€” so .b(Never) could be avoided during decoding, but .b(MyNonInstantiableStruct) could not.

Obviously, MyNonInstantiableStruct is a useless type here, but you can easily end up with a Never deeply nested within a type hierarchy, that in the general case, the compiler would need to search for if we wanted to generalize this concept.

I think whether we should formalize this is a good question, both philosophically and practically. It's easy to do the bare minimum here and get rid of the most obvious of foot-guns, but being 100% consistent would be tricky and take more work, potentially.

Though, handling this could easily be additive ā€” and it's quite possible that it's simply enough to have Never throw and call it a day.

It is not necessarily a logic error to attempt to decode Never, because you may have a generic codable type with variants that are sometimes allowed, but sometimes not. Using Never as a type parameter to indicate that is a natural use of the type system, as is writing generic code to handle both the all-variants-available and some-variants-unavailable cases uniformly. Decoding an invalid input file that attempts to represent the invalid case in such situations isn't the programmer's fault any more than other forms of data corruption would be. Along those lines, I also don't know that it's a particularly good idea to specialize the coded representation of generic enums when all but one of their cases is unavailable, since that would make handling the representation in generic code more complex.

11 Likes

I was going to contest this but after thinking about it I have some doubts, so I'll mostly leave this aside. I'll just say that I think it's possible that there is truly no possible use for any uninhabited type besides Never and therefore it is fine to build special logic into the compiler surrounding Never (as there already is plenty of, I believe).

I don't think I understand what searching you're referring to that the compiler would have to do. I'm proposing that if it can be statically determined that (any of) the associated value(s) of any of the cases of an enum are equal to Never then the synthesized Codable conformance acts as if those cases did not exist.

Checking my understanding of how generics work under the hood as related to this ...

I've absorbed information about this over the years but never in a rigorous way. It is my understanding that generic type definitions serve as templates which the compiler uses to generate separate type definitions for each combination of generic parameters that your code actually uses. This leads me to believe that if I choose to use Result<Int, Never> in my code then the compiler will detect this and will generate a definition for that exact type, and this definition will include any synthesized conformances, which makes me believe that the change I'm proposing is at least perfectly feasible given the compiler's architecture (?)

----

I'm not clear on the potential problem you're referring to. When you say "to specialize the coded representation of generic enums when all but one of their cases is unavailable", my understanding is that you're referring to the e.g. JSON representation of e.g. Result<Int, Never>, which concretely would be something like: {"success":7}, but I don't think that I'm talking about specializing that representation.

Now that I'm back at my computer I have checked the actual error that's thrown from my AorB example above, and it's a bit confusing to me:

typeMismatch(A, Swift.DecodingError.Context(codingPath: [], debugDescription: "Invalid number of keys found, expected one.", underlyingError: nil))

Regardless, my suggestion was just that maybe it would make most sense for the following two assertions to fail with the same type of error:

try AorB
    .b(7)
    .asJSONData()
    .jsonDecoded(as: A.self)

try Result<Bool, ErrorMessage>
    .failure(ā€œAn error occurredā€.asErrorMessage())
    .asJSONData()
    .jsonDecoded(as: Result<Bool, Never>.self)

The Either example is not convincing. But I don't need to be convinced. This will be a great addition. Not because Codable is special, but because it's ubiquitous.

You've surely got secret stuff in the works to support that is more important than what we currently know to be the greatest advantage of the additionā€”bringing the ability to write this kind of code:

struct Type<Value: Codable>: Codable {
  let value: Value
}

extension Type<Never> {
  enum FakeNonGenericNamespace {
    static func member() { }
  }
}

Type.FakeNonGenericNamespace.member()

As for the error, nothing that exists is appropriate. Make a new one.

extension Never: Decodable {
  public struct DecodingError: Error {
    let context: Swift.DecodingError.Context
  }

  public init(from decoder: Decoder) throws {
    throw DecodingError(
      context: .init(
        codingPath: decoder.codingPath,
        debugDescription: "To die will be an awfully big adventure. šŸ§š"
      )
    )
  }
}

Sure, I agree ā€” there's no benefit to rolling your own Never type; but it is possible.

What I meant to express more clearly, though, was that it's trivially easy to create a type which can contain a Never, and is thus just as non-instantiable; but it isn't Never:

struct S<T> {
    let t: T
}

S<Never> can't be instantiated, but it isn't equivalent to Never. Neither is S<S<Never>>, S<S<S<Never>>>, etc.

(I'm reusing S here, but the idea is that you can have a chain of generic types arbitrarily deep where somewhere, a Never is stored which makes the whole type non-instantiable.)

What I was trying to express was that what you're suggesting here is easily possible and works for Never ā€” but if we wanted to also have it work for S as written above (so that S<...<S<Never>>> is also elided) for the same reasons as we do for bare Never itself, things get more complicated.

I'm not advocating for this; it was mostly a parenthetical statement regarding consistency across other non-instantiable types.

2 Likes

I think that we could define the following three things as each its own form of attempt to roll one's own Never:

  1. Defining an enum with no cases (if you're not using it as a namespace), e.g.:
enum MyNever { }
  1. Effectively, removing all of the cases from a generic enum using Never, e.g.:
Result<Never, Never>
  1. Using Never in a product type, e.g.:
struct Bizarre {
    var when: Int
    var wouldYouDoThis: Never
}

I think you're already on board with the idea that no one needs to roll their own Never. It seems to me that if you buy my characterization of your S<Never> struct as being as much of a self-rolled Never as the MyNever struct from above (which maybe you don't) then I think that the logical conclusion is that it would not be "inconsistent" for a developer to observe Never behaving differently from all other manner of uninstantiatable type, be it MyNever, Result<Never, Never>, S<Never>, S<S<S<Never>>> etc...

I can't resist pointing that it could be clearer to retitle this:
Conform "Never" to Codable
as I want to read the title as a screed against ever conforming to Codable. ;)

3 Likes
Answering question about generics

This is how things work in Rust and C++, but not in Swift. Type definitions are generated at run time for each combination of generic parameters your code actually uses. In some cases the compiler can do that work ahead of time, or will make assumptions about the resulting type that the runtime will build, but the language model is not allowed to depend on that. That doesn't mean there are no special casesā€”Optional is very special at both compile time and run timeā€”but it's not as simple as the compiler seeing Result<Foo, Never> and going "oh, okay, I can omit the enum tag altogether for this one".

13 Likes