Type narrowing in a scope conditioned by type equality

I haven't found a mention of this in the Generics Manifesto. Sometimes it's necessary to check whether a generic parameter is equal to an expected type and then perform some operations on the parameter. There are examples in the stdlib, such as in the code snippet below:

  override internal func encodeNil<K: CodingKey>(forKey key: K) throws {
    _internalInvariant(K.self == Key.self)
    let key = unsafeBitCast(key, to: Key.self)
    try concrete.encodeNil(forKey: key)
  } 

Similar examples exist in open-source projects, like this one.
The issue here is that we have to use unsafeBitCast or as! , even though we have already checked that K and Key are equal. It would be nice if these types could be interchangeable within scopes guarded by this type of condition.

  override internal func encodeNil<K: CodingKey>(forKey key: K) throws {
    guard K.self == Key.self else {
      fatalError()
    }
    try concrete.encodeNil(forKey: key)
  }
1 Like

Is there a reason that guard let key = key as? Key wouldn’t work here? No need to check the types before the cast.

The stdlib has recently gained an underscored utility method that is within this area:

Adopting it within the stdlib has proved a bit of a pain due to unexpected performance regressions, but I think it's going to become the recommended library-level solution for cases like this, at least within the stdlib. Its intended use case is for conditional narrowing within manual specializations, but I think it would also be suitable for unconditional narrowing.

This is a purely library-level solution that does not require language-level changes.

1 Like

It's reasonable to want to bypass dynamic casting for a number of reasons:

  • Dynamic casting is expensive, because
  • Dynamic casting involves a lot of magic behavior, combining conformance checking, same-type checking, Foundation bridging, etc. that isn't always desirable,
  • Dynamic casting tends to copy values,
  • You may want to ask about a type's capabilities without having a value handy

so I think having a feature that allows for these kinds of type-narrowing queries would be very valuable. It's come up a few times in the past, but I like the idea of introducing a new kind of statement condition that can be used in if/guard/while:

if where K == Key {
  try concrete.encodeNil(forKey: key)
}

which can also generalize to cases that need to introduce new generic parameters, protocol conformances, etc., for example if you want to test if a type is some kind of Array:

if <U> where K == Array<U> {
  ...
}
7 Likes

TypeScript has something like this using the infer operator. Due to limitations of the language it can only be used at compile-time and not runtime, but it looks something like this:

K extends (infer U)[]
  ? /* U is in scope here */
  : /* U is not in scope here */
1 Like

I guess it's unreasonable to support such type of condition within while since a generic parameter won't change between iterations.
Then if it's not used with while, but only if and guard it could be simplified to just where:

where K == Key {
  try concrete.encodeNil(forKey: key)
}

or even if

if K == Key {
  try concrete.encodeNil(forKey: key)
}

(But with if it may be confusing, I guess).
I don't see much of a difference between K.self == Key.self and K == Key. But really like your idea to generalize such conditions and allow to introduce new generic parameters (btw, this generalization is kinda needed in many more places in the language).

Not necessarily, if you consider metatype variables. We could allow something like:

var x: Any.Type = Foo.self

while where x: Protocol {
  update(&x)
}

Hm. You mean it covers cases when Protocol is a protocol with associated types? What type x will have within while scope? Any.Type & Protocol ?

I was imagining if where to be somewhat more similar to @_specialize — even if not in code generation per se, at least in the fact that it only operates on actual types and not type objects.

You can often get away with casting one value to the type of another (à la @bbrk24's suggestion) but there are occasionally situations where you need to validate multiple constraints, and simple casting can't get you there (since you can't insert where clauses into a cast, and we don't have the if where swiss knife yet).

There's actually a really neat workaround in situations like these, based on conditional conformance + the "witness" pattern. I first saw this used in The Composable Architecture for a type-erased Equatable test, but it can be extended a lot further. The general idea is to create a dummy generic type that conditionally implements a method inside a where constrained extension. For example:

private protocol EquatableWitness {
  var areEqual: Bool { get }
}
private struct Witness<L, R> {
  let lhs: L
  let rhs: R
}
extension Witness: EquatableWitness where L: Equatable, L == R {
  var areEqual: Bool { 
    // we're effectively inside `if where L: Equatable, L == R` here
    lhs == rhs
  }
}
func areValuesEqual<L, R>(lhs: L, rhs: R) -> Bool {
  let witness = Witness<L, R>(lhs: lhs, rhs: rhs)
  if let equatableWitness = witness as? any EquatableWitness {
    // we've proven to the compiler that the constraints
    // are met, with just one test
    return equatableWitness.areEqual
  } else {
    // the constraints weren't met
    return false
  }
}

Another hypothetical example — this one uses parameterized existentials but there are slightly less efficient ways to achieve the same thing without them. One can observe that this pattern is especially useful for type erasure.

private protocol CollectionWitness<C, E> {
  associatedtype C
  associatedtype E
  func replaceFirstElement(of collection: inout C, with value: E)
}
private struct Witness<C, E> {}
extension Witness: CollectionWitness where C: MutableCollection, C.Element == E {
  func replaceFirstElement(of collection: inout C, with value: E) {
      // if where C: MutableCollection, C.Element == E
      collection[collection.indices.first!] = value
  }
}
func replaceFirstElement<C, E>(of collection: inout C, with value: E) {
  if let witness = Witness<C, E>() as? any CollectionWitness<C, E> {
      witness.replaceFirstElement(of: &collection, with: value)
  }
}

In the general case, say you have generic parameters A, B. You can implement if where COND { BODY } over A, B as

private protocol WitnessProtocol<A, B> {
  associatedtype A
  associatedtype B
  func body(a: A, b: B)
}
private struct Witness<A, B> {}
extension Witness: WitnessProtocol where COND {
  func body(a: A, b: B) {
    BODY
  }
}
func method<A, B>(a: A, b: B) {
  if let witness = Witness<A, B>() as? any WitnessProtocol<A, B> {
    witness.body(a: a, b: b)
  }
}
1 Like

I believe you can avoid the force-unwrap here with collection.startIndex.

ah thanks, I knew there was a better way to do it but I couldn't recall the property name (worth noting that you could still crash if startIndex == endIndex, so a safer impl would validate !isEmpty regardless.)