[Pitch] Small Enhancement to Enumerations

There is (what seems to me to be) a simple addition to Enumerations which would make their use easier (particularly when working with CoreData). Let me explain, using the following example:

enum Region: String, CaseIterable {
case northEast = "NorthEastern States"
case midAtlantic = "MidAtlantic States"
case southEast = "SouthEastern States"
case northCentral = "North Central States"
case southCentral = "South Central States"
... etc
}

In other words, I use the rawValue of each enumeration case to be the text I use to present that case to the user. (clue: I'm heading towards localization issues.)

To store a value of this enum in a CoreData database, I define an attribute named "regionStr" with a string data type. At present, I store the enum's rawValue in this attribute, and then define:

public var region: Region {
get {
return Region(rawValue: regionStr)
}
set {
regionStr = newValue.rawValue
}
}

This ALMOST works right. But this approach makes localization complicated and error-prone. What I want is to store a language-independent value in the database and derive from it (presumably using rawValue) a localizable value to present to the user.

PROPOSED SOLUTION:
Expose the enumeration's case labels with an initializer and a read-only getter. I.e.,

init(label: String)

public private(set) var label: String

This would allow me to use the case label as the CoreData stored value and be able to use the rawValue as the (localizable) human-facing description of that case. It would be simpler to use and simpler to maintain than any other solutions I can currently think of.

At least in my mind, this enhancement is purely additive, so it should not break any existing code.

If you don't provide a raw value, each case will automatically get its label as String raw value:

enum Region: String {
  case northEast
  case midAtlantic
  case southEast
  case northCentral
  case southCentral
}

is equivalent to

enum Region: String {
  case northEast = "northEast"
  case midAtlantic = "midAtlantic"
  case southEast = "southEast"
  case northCentral = "northCentral"
  case southCentral = "southCentral"
}

I'm not sure I see the problem. You can do something like this:

enum Region: String, CaseIterable {
    case northEast = "NorthEastern States"
    case midAtlantic = "MidAtlantic States"
    case southEast = "SouthEastern States"
    case northCentral = "North Central States"
    case southCentral = "South Central States"
    var localizedName: String {
        if #available(macOS 12, iOS 15, *) {
            return String(localized: String.LocalizationValue(rawValue))
        }
        else {
            return NSLocalizedString(rawValue, comment: "")
        }
    }
}

Then use region.localizedName for presentation. Is that what you were looking for, or did I misunderstand the question?

3 Likes

The issue is that these strings are used both for the display of the value (for which your solution works great) and for the capture/editing of the value. That is, I need to populate a menu with a list of (localized) values. When the user selects one, I need to translate that back to the unlocalized value (presumably by translating the selected item number to an index in the allCases array) and then storing the unlocalized allCases item value.

This is certainly doable, but I'm hoping for something a bit less cumbersome.

Yes, certainly. BUT what happens when the text I supply the user (either as a display value or as a menu item for data capture or editing) isn't representable that way. For example, suppose I want to provide my users with a human-readable string instead of a camel-case constant?

(Assuming that you are targeting MacOS), is it that cumbersome? You can assign a tag to each menu item, and make that tag the index of that case’s index from Region.allItems

Does not sound cumbersome, IMO. Would having a stored label even make any difference?

Also adding stored label creates confusion about equality - if you get two values with identical rawValue, but different labels, they should be considered equal in one context and different in other.

Almost any system provided capability, that allows a user to choose from a list or menu, will have a way of separating the identity of each item from its displayed representation.

Otherwise, if you're doing this entirely in your own code, you could construct — once — a dictionary whose keys are localized region strings and whose values are Region cases, you can get from the user's menu selection to a Region in a single dictionary lookup.

That's one line of code (using map) to create the dictionary, and one line of code to look up the dictionary value. Is that really "cumbersome"?

OTOH, how would any enhancement to enum help? Even if the individual cases could store the localized string, how would you actually get from the user-chosen menu string back to the matching enum case?

You should probably iterate over the allCases property when populating the menu, and capture the value in the menu's action handler. This is easy in both SwiftUI and UIKit.

Menu("Region") {
    ForEach(Region.allCases, id: \.rawValue) { region in
       Button(region.localizedString) { self.selectedRegion = region }
    }
}

Or even

Picker("Region", selection: $selectedRegion) {
    ForEach(Region.allCases, id: \.rawValue) { region in
       Text(region.localizedString).tag(region)
    }
}
let menu = UIMenu(
    title: "Region", 
    children: Region.allCases.map { region in
        UIAction(title: region.localizedString, action: { _ in self.selectedRegion = region })
    }
)

I would like to thank everyone who responded to my (ill-advised) pitch regarding enumerations, and particularly to Quincey Morris who offered some excellent ideas (in particular the delocalizing dictionary). As a result of these suggestions, I've developed the following pattern that I use for working with enumerations in CoreData:

Let's say that we want to store an enumeration named Region in a CoreData entity named Country. We do so by defining a String attribute named regionStr in Country. Then in an extension to Country,

extenion Country {
    enum Region: String, CaseIterable {
        case unspecified = "(unspecified)"
        case northEast = "NorthEastern States"
        case midAtlantic = "MidAtlantic States"
        case southEast = "SouthEastern States"
        case northCentral = "North Central States"
        case southCentral = "South Central States"
        ... etc
    
        var localizedValue: String {
            get {
                rawValue.localized
            }
        }
    
        static private var dictionary: [String : String] =
            Dictionary(uniqueKeysWithValues: allCases.map { 
                ($0.rawValue.localized, $0.rawValue) })
    
        static func delocalizedValue(_ str: String) {
            Region.dictionary[str]
        }
    
        static func array() -> [String] {
            allCases.map { $0.rawValue.localized }
        }
    
        public init?(localized: String) {
            self.init(rawValue: Region.dictionary[localized] ?? unspecified.rawValue)
        }
    }

    public var region: Region {
        get {
            regionStr.isNilOrEmpty ? .unspecified : Region(rawValue: regionStr!) ?? .unspecified
        }
        set {
            regionStr = newValue.rawValue
        }
    }
}

where "localized" and "isNilOrEmpty" are the obvious extensions to String.

While working, I think your solution is fragile and prone to string conversion errors. It is also inefficient (although that's less of a problem with just a handful of cases as in your example)

You're building your UI based on String values, when it should be built from Region values instead.
And you're reacting to user input through String values, when you should be using Region values.

But if your boat floats, I guess it works ¯\_(ツ)_/¯

As a rule, String typing is bad and should be avoided.

Actually, I'm not building my UI around String values per se. What I'm doing is building a UI (backed by CoreData for data-persistence) that includes a number of enumerations. The issue is that the enumerations require us to maintain two synchronized representations: one for data storage (since the enumerations can't be stored in CoreData directly) and one for the user (that includes both display and data input [via menu selection]). And obviously, the user-facing representation needs to support localization.

From the CoreData perspective (since enumerations can't be stored directly), I think there are only three choices: store an integer (which is hopelessly fragile unless you completely freeze the enumeration's definition), a string (which isn't a lot better, but which at least combines the enumeration's representation in CoreData with it's user-facing representation), or as Data (via a Transformable attribute).

I haven't explored the Transformable option, but I'll look into it.

And by the way, I agree about String typing. My UI uses selection (menus, pickers, what-have-you) for data entry. Both user display and user selection require the ability to keep enumeration case values synchronized with (localizable) strings, which is why I define the enumerations as I do.

I really want to build a technically-sound UI for this app. I'm neither stupid nor a complete newbie. And I am open to better approaches than I've been able to come up with to date. So if you have better ideas, I would be very receptive.

I think the idea of using strings to represent enums in the DB is a sound one.
I also think it is a good idea to extend your enums with some kind of computed property that returns a user-facing localized string suitable for the UI.

However, I think it is a code smell to use that user-facing string as an input elsewhere in your program. In your menu-selection action handler, you should not read off the value from the menu item as shown to the user. The menu action should capture the real enum value, not the user-visible string.

4 Likes

So if I understand you correctly, what you are saying is the the conversion from user input to enumerate value should happen as early as possible (namely in the menu action handler) and that (Core Data quirks aside), I should treat the stored string representation in the DB as private (and hence inaccessible outside the Core Data entity.

I couldn’t agree more.

Terms of Service

Privacy Policy

Cookie Policy