Automatically Synthesize Identifiable Conformance for Enums

Many types in Swift, namely value types, benefit from the ability to automatically conform to certain protocols if they satisfy certain conditions. For example, if an enum in Swift either has no associated values or has all Equatable associated values, then it can get an automatic Equatable conformance (e.g. a synthesized static func == (lhs:rhs:)) just by marking the enum with the Equatable protocol conformance. Same for Hashable and various other protocol conformances (and the same is true for structs as well).

For example:

enum SomeEnum: Equatable {
  case a(SomeEquatableStruct)
  case b
}

The above has no need for a manual static func == (lhs: Self, rhs: Self) to be written. However, even though adding an Identifiable conformance for enum types is trivial*, marking an enum as Identifiable still requires that a manual id property be written out. I'm curious if there would be some interest in changing this through an SE proposal to allow for automatically synthesizing an id property for enums marked with Identifiable.

* For enums with no associated values, simply using Self is enough for the ID type. For enums with associated values there is a bit more complexity: the enum type itself must be marked as Hashable (and can use the auto-synthesized conformance) and each associated value must also therefore be Hashable) before Self can be used as the ID type.

Examples for both an enum with no associated values and an enum with associated values is shown below:

enum SomeEnum: Identifiable {
  case a
  case b

  // The discussion is about if the following
  // should be automatically synthesized
  var id: Self { self }
}
struct SomeStructA: Hashable { ... }
struct SomeStructB: Hashable { ... }

enum SomeEnum: Hashable, Identifiable {
  case a(SomeStructA)
  case b(SomeStructB)
  case c

  // The discussion is about if the following
  // should be automatically synthesized
  var id: Self { self }
}

I would welcome any thoughts and discussion about this. :slight_smile:

EDIT 1: I forgot to mention that AnyObject does have an extension that adds an automatic Identifiable conformance based on its ObjectIdentifier. While not quite the same as the automatic synthesis I describe above, it does show that there's a bit of precedence for the idea.

EDIT 2: After more discussion, it is clear that a simple Hashable requirement for associated values is not enough. Instead, associated values themselves should be Identifiable in a similar vein to how synthesizing Equatable or Hashable conformances requires that their properties are also Equatable or Hashable, respectively. There is some discussion about if/how enum cases with multiple associated values would need to handle Identifiable conformance: with the most straightforward requirement being that each case would need to have either no associated values or each associated value conform to Identifiable (where the Identifiable conformance would be a combination of the case "name"/"tag" and (if existing) each associated values' Identifiable id). For example:

struct SomeStructA: Identifiable { ... }
struct SomeStructB: Identifiable { ... }

// The discussion is about if the following conformance
// should be automatically synthesized for each case
enum SomeEnum: Identifiable {
  case a(SomeStructA) // Would use the `a` name/tag and `SomeStructA`'s `Identifiable` `id`
  case b(SomeStructB) // Would use the `b` name/tag and `SomeStructB`'s `Identifiable` `id`
  case c // Would only need to use `c`'s name/tag
}
4 Likes

In theory, it sounds nice—this can be handy when you know you won't be dealing with any duplicates. In actuality, enumerations (and other Hashable types) are unsuitable for the semantics of Identifiable, in the general case. This should be better documented.

E.g. try interacting with this nightmare:

import SwiftUI

struct UnusableView: View {
  enum Case: Identifiable {
    case a, b
    var id: some Hashable { self }
  }

  @State var cases: [Case] = [.a, .b, .a, .b]

  var body: some View {
    List($cases, editActions: .move) { $case in
      Text("\(`case`)" as String)
    }
  }
}

#Preview(body: UnusableView.init)
1 Like

I think the main issue with this use of Identifiable within SwiftUI's List (which holds true for ForEach as well) is that the collection being used (cases in this instance) violates this principle (stated in the Identifiable documentation as one of the recommended way to ensure identify scope):

  • Unique within the current collection, like collection indices.

List/ForEach requires that a the collection it uses does not contain duplicates. Your example also breaks down when modified to use a regular struct as well if the collection contains such structs with duplicate ID values:

struct UnusableView: View {
  struct SomeStruct: Identifiable {
    let id: String
  }

  @State var structs: [SomeStruct] = [
    .init(id: "a"),
    .init(id: "b"),
    .init(id: "a"),
    .init(id: "b")
  ]

  var body: some View {
    List($structs, editActions: .move) { $struct in
      Text("\(`struct`)" as String)
    }
  }
}

#Preview(body: UnusableView.init)

However, after looking over this, I do this that it makes me second-guess introducing the Hashable requirement at all for the associated value cases. I believe that instead it would make more sense to require that associated values have already conformed to Identifiable and instead forward the individual cases to those ID values, assuming that there is a way to combine the case with the id property (since a(SomeStruct) should still be unique from b(SomeStruct) even if SomeStruct in these cases have the same id value).

No, it doesn't do this for me in case of an enum with associated values.

I was surprised to hear that Hashable has any influence on Identifiable auto-synthesising, so I double checked (and it doesn't).

In fact the first example (without associated values) doesn't work for me either...

id should be explicit... if you want it to be var id: Self { self } - that's fine (if it works for you), just say so explicitly.

I don't think I was very clear in my original posting; the discussion is about if the id should be automatically synthesized in the cases where it can be. I have updated the code examples to make that more clear with the inline comments.

Most of my enums are used in this exact way (with Hashable) so I wouldn't mind this.

I don't think that's right, as per rlzii's reply, the issue is that your collection violates the semantic requirements of Identifiable. Your argument could be extended to say that String or Int are also unsuitable identifiers.

When I have associated values I do think using the associated value's id would be more appropriate than just Hashable, in that case the generated conformance could look like this:

enum SomeEnum: Identifiable {
  case a(SomeIdentifiableStruct)
  case b(SomeHashableStruct)
  case c
}

// this would be automatically synthesized
extension SomeEnum {
  enum ID: Hashable {
    // use ID if possible
    case a(SomeIdentifiableStruct.ID)
    // else use their Hashable conformance
    case b(SomeHashableStruct)
    case c
  }
  var id: ID { /* ... */ }
}

If we had macros with semantic context we wouldn't even need this pitch!

1 Like

Thank you for the clarification!

Still I do not think this is the right "default"... Without a proper notion of identity things like animations would be broken and having this behaviour by default and working out of the box could "encourage" that situation, whilst with a compilation error I'd have a chance of thinking about it and noticing the error early.

Broken animation example
import SwiftUI

enum Item: Hashable, Identifiable {
//    var id: Self { // uncomment this to see broken animation
//        self
//    }
    var id: Int {
        switch self {
            case let .color(id, _, _, _):
                10000 + id
            case let .box(id, _, _):
                20000 + id
        }
    }
    case color(id: Int, red: Double, green: Double, blue: Double)
    case box(id: Int, width: Double, height: Double)
}

class Model: ObservableObject {
    @Published var items: [Item] = [.color(id: 1, red: 1, green: 0, blue: 0), .box(id: 2, width: 100, height: 100)]
    
    init() {
        Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { _ in
            withAnimation {
                self.items[0] = .color(id: 1, red: 1, green: 1, blue: 0)
            }
        }
        Timer.scheduledTimer(withTimeInterval: 10, repeats: false) { _ in
            withAnimation {
                self.items[1] = .box(id: 2, width: 200, height: 50)
            }
        }
    }
}

struct ContentView: View {
    @StateObject private var model = Model()
    var body: some View {
        List(model.items) { item in
            switch item {
                case let .color(id: _, red: red, green: green, blue: blue):
                    Color(red: red, green: green, blue: blue)
                        .frame(width: 100, height: 100)
                case let .box(id: _, width: width, height: height):
                    Color.black
                        .frame(width: width, height: height)
            }
        }
    }
}

#Preview {
    ContentView()
}

@main struct ST2App: App {
    var body: some Scene {
        WindowGroup { ContentView() }
    }
}

That is not my argument. Any Hashable type may be a good representation of stable identities, but only if its instances are all unique (via Equatable) within a given context—this is Identifiable's compiler-unenforceable contract. As there is no mechanism to enforce that all instances of a type are unique, within any context, it is not generally, or usually, correct, for any type's unique identifier to be its own instance. (Uninhabited types are the exception because, as you can't have any instances of them, you can't have duplicates.)

Although using an "id" parameter with \.self cannot guarantee uniqueness, it is the best that the language offers—leaving it up to you, to not lie. I think this is probably always the right way to go, even for controlled-scope enums, but using self as id can be a useful statement about your program. I don't think the use cases for this warrant any automatic synthesis, but I'd be interested to learn otherwise.

SwiftUI demonstration of Int behaving as an ID, and not.
import SwiftUI

extension Int: @retroactive Identifiable {
  public var id: some Hashable { self }
}

struct IntsView: View {
  @State var ints = [1, 2, 1, 2]

  var body: some View {
    list("Broken") {
      List($ints, editActions: .move) { $int in
        Text("\(int)")
      }
    }
    list("Not Broken") {
      List {
        ForEach(ints.enumerated(), id: \.offset) { int in
          Text("\(int.element)")
        }
        .onMove { source, destination in
          withAnimation {
            ints.move(fromOffsets: source, toOffset: destination)
          }
        }
      }
    }
  }

  func list(_ title: String, @ViewBuilder view: () -> some View) -> some View {
    VStack {
      Text(title)
      view()
    }
  }
}

#Preview(body: IntsView.init)

This is a bad idea.

Whilst I agree that there's only one sensible Identifiable conformance for an enum without associated values, and maybe it's less controversial to generate that by default, for enums with associated values, there's no way for the compiler to know how Identifiable needs to work.

Identifiable conformances have to be designed carefully, and (at the least) you'd need attributes to mark up whether each associated value should be

  • ignored for identifiability (it's part of the value, not part of the ID)
  • included in identifiability (it's the ID, or part of it)
  • or some part of the associated value should be included in the ID (possibly with a special case for using the value's own ID?).

That is, something like this might work:

@Identifiable
enum Whatever {
    case nothing
    case person(@Identity(\.id) Person)
    case publishedBook(@Identity isbn: ISBN, title: String, author: Person)
    case unpublishedBook(@Identity title: String, @Identity(\.id) author: Person)
}

generating something like

extension Whatever: Identifiable {
    enum ID: Hashable {
        case nothing
        case person(Person.ID)
        case publishedBook(isbn: ISBN)
        case unpublishedBook(title: String, author: Person.ID)
    }
    var id: ID {
        switch self {
        case .nothing: .nothing
        case let .person(person): .person(person.id)
        case let .publishedBook(isbn, _, _): .publishedBook(isbn: isbn)
        case let .unpublishedBook(title, author): .unpublishedBook(title: title, author: author.id)
        }
    }
}

Unfortunately, this is just out of reach of macros today, since they can't be attached to the associated values, and if they're attached to the cases, there's no way to make they keypaths typesafe.

4 Likes

We could add a macro to Swift / standard library, without a pitch?
Or do you mean providing a "third party" macro?

+1

The first two could be expressed explicitly † without too much boilerplate with var id: Int { discriminator } and var id: Self { self } respectively, where discriminator a wanted/missing feature to get some Integer (or another type?) that corresponds to the enumeration case index.

A side note on enum discriminators

This is the trick I am using to get it using the side effect of CustomNSError:

import Foundation

// Infrastructure

protocol EnumWithDiscriminator: CustomNSError {
    var discriminator: Int { get }
}
extension EnumWithDiscriminator {
    var discriminator: Int { errorCode }
}

// Test code

enum E: EnumWithDiscriminator {
    case a // 4
    case b // 5
    case c // 6
    case d(String) // 0
    case e(Int) // 1
    case f(Int) // 2
    case g(Int) // 3
}

[E.a, .b, .c, .d(""), .e(42), .f(42), .g(42)].forEach { e in
    print("\(e) -> \(e.discriminator)")
}

outputs:

a -> 4
b -> 5
c -> 6
d("") -> 0
e(42) -> 1
f(42) -> 2
g(42) -> 3

The third one is IMHO not worth having... You might want to lowercase the payload string, or combine the two payload strings, or do some arithmetic on the associated value, and so on... Just write that explicitly in your var id: T { ... } implementation.


† Edit: I now see that you were describing the individual fields of associated values to either be part of ID or not, whereas the approach I am describing is "all-or-nothing".

The latter, we could make it ourselves and publish the package.

FWIW a few packages already exist.

This is one I maintain GitHub - swhitty/identifiable-macro: macro for Swift enum conformance to Identifiable

1 Like

exactly; I see several common cases regularly, often in the same enumeration:

enum MyTableCell {
    /// there's only ever one instance of this cell; it is its own ID
    case aboutThisApp
    /// this cell contains its own ID as well as some display data
    case book(ISBN, title: String, author: String)
    /// this cell contains a reference to some identifiable object, whose ID is our own
    case bookReference(Book)
}

struct ISBN: Hashable { ... }
final class Book: Identifiable { ... }

So in this case the appropriate Identifiable conformance is

extension MyTableCell: Identifiable {
    enum ID: Hashable {
        case aboutThisApp
        case book(ISBN)
        case bookReference(Book.ID)
    }
    var id: ID {
        switch self {
        case .aboutThisApp: .aboutThisApp
        case let .book(isbn, _, _): .book(isbn)
        case let .bookReference(book): .bookReference(book.id)
        }
    }
}

But that doesn't mean there aren't more cases that are reasonable — in general, you should be able to select any (Hashable) subset of the associated data to become part of the ID.

3 Likes

Great example!

BTW, this simple trick could avoid having an extra ID type:

extension MyTableCell: Identifiable {
    var id: Self {
        switch self {
            case .aboutThisApp: self
            case let .book(isbn, _, _): .book(isbn)
            case let .bookReference(book): .bookReference(Book(id: book.id))
        }
    }
}
Full example
enum MyTableCell: Hashable {
    case aboutThisApp
    case book(ISBN, title: String = "", author: String = "")
    case bookReference(Book)
}

struct ISBN: Hashable {
    var value: String
}

struct Book: Identifiable, Hashable {
    var id: Int
    var abstract: String = ""
}

extension MyTableCell: Identifiable {
    var id: Self {
        switch self {
            case .aboutThisApp: self
            case let .book(isbn, _, _): .book(isbn)
            case let .bookReference(book): .bookReference(Book(id: book.id))
        }
    }
}

While you can use MyTableCell as its own ID, I don't think it's good practice. In order to force MyTableCell into being suitable as an identifier, you have to break the invariant that the book case's title and author always contains useful information. You'll end up with two kinds of MyTableCell instance: one that actually represents a table cell and is suitable for that purpose, and one that represents a table cell's identifier; and you'll become responsible for making sure one kind of MyTableCell isn't used as the other kind accidentally. To ensure this, you'll have to write extraneous documentation and, potentially, dynamic runtime checks. Additionally, the identifier kind of MyTableCell will have the overhead associated with copying title and author; even though they're empty and unused.

By keeping MyTableCell and its ID type separate, the roles and intended usage of each type becomes more clear. While it requires a little more code, it pays off in clarity.

6 Likes

The type needed for ID is not useful outside of the id property.

extension MyTableCell: Identifiable {
  var id: some Hashable {
    enum ID: Hashable {
      case aboutThisApp
      case book(ISBN)
      case bookReference(Book.ID)
    }

    return switch self {
    case .aboutThisApp: .aboutThisApp
    case .book(let isbn, _, _): .book(isbn)
    case .bookReference(let book): .bookReference(book.id)
    } as ID
  }
}

I like that macro idea but it's too much noise in the main type. I don't think macros can be used to contain the necessary hashable selection within the id property, any better than writing out what's above, but I'd like to be wrong.

Yep, good points.

If we do consider doing this for Identifiable we probably want to consider doing it for Equatable/Hashable/Codable as well? And not just for enums?

Yeah sometimes i just wish I could just expand and replace a macro keeping the code.

1 Like

I think you could make this work as a macro:

struct Identifying<T, Args> {
    init<each Component>(
        _ constructor: (Args) -> T,
        by components: repeat KeyPath<Args, each Component>
    ) {}

    init(
        _ value: T
    ) where Args == () {}
}

@attached(extension, conformances: Hashable)
@attached(member, names: arbitrary)
@attached(peer, names: named(id))
macro Identity<T, each Args>(
    _ cases: repeat Identifying<T, each Args>
) = #externalMacro(module: "A", type: "A")

enum Whatever: Identifiable {
    case nothing
    case person(Person)
    case publishedBook(isbn: ISBN, title: String, author: Person)
    case unpublishedBook(title: String, author: Person)

    @Identity(
        Identifying(Whatever.nothing),
        Identifying(Whatever.person, by: \.id),
        Identifying(Whatever.publishedBook, by: \.0),
        Identifying(Whatever.unpublishedBook, by: \.0, \.1.id),
    )
    enum ID {}
}

(maybe even attach it as a body macro to var id: some Hashable {} instead of to the ID type, though I'm unconvinced by Danny's assertion that you always want to hide the details of the ID type)

But like everyone else in this thread, I'm unsure that that's any improvement over just writing the full conformance by hand.