How to make a custom enumeration for Unity? -> full guide + code

Introduction
First of all, this is one of those things where people just can’t agree with each other. Should you use enum, or should you build an OOP system surrounding your types, or maybe it’s ScriptableObject that is used both as the thing that goes into the thing, and the wrapper thing held above the another ScriptableObject which enumerates mini ScriptableObjects… meh, you get the point. We can always weigh pros and cons of each approach and certainly there are numerous benefits to be had from a robust design solution, but I won’t go further into this discussion.

Let’s just assume there are legit reasons why someone would really want to use an enum inside their project, and not just use them as tiny human-readable tokens for some system settings within the code, never to be seen again, but throughout the game logic as well, sprinkled in critical places, as contextual lightweight atomic data.

“Okay so let’s just use enum” Uhuh, well, you see, there’s a problem with how C# enums work and how Unity treats them. THEY ARE TERRIBLE for any kind of iterative design: you set them up, you give them names, you give them values, you’re better not to touch them ever again, especially in a collaborative environment. If you don’t give them explicit values, then you must not reorder them as well. In my early days, I remember having just a few enum classes to describe my monster categories and certain damage types. By the end of that project these couple of classes had more commentary in them (DON’T MOVE THIS, DON’T TOUCH THAT) then any other meaningful C#.

The other, HUGE problem with enums in Unity is that they serialize as their values. Now imagine if you had four monsters: Duck, Beaver, Rabbit, and Possum. What should their values be? It doesn’t matter right?

So you’d write it as such

public enum TerribleMonsters {
  Duck,
  Beaver,
  Rabbit,
  Possum
}

But then you choose a monster from a drop-down in some inspector field, maybe you’d like some behavior to associate with Possum, for example. And this is what saves to YAML

_selectedMonster: 3

Does this mean anything to you? It’s supposed to mean something to you, if you ever need to repair (or merge) your files. Also, what do you imagine will happen to your project if you suddenly remove the Beaver because it, idk, doesn’t fit the theme or whatever, or if you add a Porcupine somewhere in between? Enums are just bad for health.

So the main goals of this exercise will be to properly serialize our data to concrete strings, as well as to provide the expected enum behavior, in code, as well as in Unity inspectors.

This is the approach: we’ll make a so-called custom enumeration class. It kind of behaves like a standard enum, but you now have to fully specify the behavior on your end, because (sadly) there is nothing else in C# to lean onto. This means we’ll have to be very creative (aka prescient) with how this is going to be used in code, but we’ll also have to create a custom property drawer to present the data in a friendly manner (i.e. keep the dropdown).

You can find more about this pattern all over the internet, even the official dot net documentation mentions it. However, I haven’t seen one for Unity because it’s quirky and difficult to get right.

Tutorial begins

That said, this is going to be an intermediate-to-heavy tutorial. It features reflection, generic programming, attributes, some newer C# syntax, and I can’t possibly explain every little detail to a rookie. If you’re more advanced than that (like me or better), maybe you’ll learn a thing or two, and if you’re an expert versed with editor-juggling and C# mastery I fully expect you to correct me if there is something you would do better.

First things first. Standard C# enum construct exists because it lets you enumerate the names quickly and conveniently without having to write too much boilerplate surrounding the basic logic of it. We can’t implement it as neatly (because we can’t redefine the language syntax), so some boilerplate is necessary, but the goal is to keep it at the minimum. For the identifiers themselves we want to have a readable listing of some kind, but you can’t just type words in C# and pretend it’ll be alright, so it must be something like const or a regular class field to declare these values properly.

We’re gonna use static public readonly fields because enum values already behave in a static read-only fashion (edit: for the remainder of this article, don’t mind me sticking static in front of public, that’s just my personal convention). Consts are compile-time things which are handled differently, so it’s better to keep it straight from the start. The aim is to create an abstract superclass that encapsulates the underlying logic, and then the user can derive from it, add his own fields, and call it a day.

So something like this at a minimum?

public class MyEnum : QuasiEnum {
  static public readonly MyEnum duck = new MyEnum("Duck", 0);
  static public readonly MyEnum beaver = new MyEnum("Beaver", 1);
  static public readonly MyEnum rabbit = new MyEnum("Rabbit", 2);
}

“But we still have to keep track of the values? And we also have to type in the names?”

We don’t actually (edit: the final product is even leaner than this). But you do want the inspector values to line up? And surely you need some values to correlate natively with the elements, it’s easy to ignore them if you don’t. I think this is the bare bone minimum for something that pretends to resemble an enum. The boilerplate is just unavoidable. You can still very compactly access the fields from the outside, i.e. MyEnum.duck and the identities are clearly defined and lined-up nicely.

We should also take care of situations where we compare MyEnum.duck == MyEnum.beaver and also provide implicit conversions to value and/or string for debugging at least, and let’s not forget about serialization.

QuasiEnum

Lets address these things by writing the core QuasiEnum class. We want each enum value to be comparable and equatable so that the rest of the system can understand how to match and compare objects of our type.

using System;
public abstract class QuasiEnum : IComparable<QuasiEnum>, IEquatable<QuasiEnum> {

  public string Name { get; private set; }
  public int   Value { get; private set; }

}

Allowing protected setters is not required (and is probably not a good idea), but we can polish this some other time. Let’s do the internal constructor next.

protected QuasiEnum(string name, int value) {
  if(name is null) throw new ArgumentNullException(nameof(name));
  if(value < 0) throw new ArgumentException("Value must be >= 0", nameof(value));
  Name = name;
  Value = value;
}

Notice the constrained value. We want the negative values to be signals for an improper deserialization, and especially later when we get to the property drawer. There are other, more flexible ways to do this, but who cares about values that much? In this design, the name is the king. The name is front and center, we’ll use the corresponding values only as a convenience, if we do need them, or literally just to be able to sort the list in value order. But negative values? Meh.

This also means the values can repeat, for example. That’s fine, two enum entries can legally have the same value. But in our scenario two names can repeat as well, and that’s illegal. But remember that we’re tying these identities to field names in C# and you can’t have more two fields with the same name, so we’re fine there.

“Ok, what’s next. Should we implement the interfaces?” Yes, let’s start with IEquatable.
Obviously with IEquatable you also want to override object.Equals(object) method, but that’s just one way to satisfy the system. We also must overload Equals with more specific matchings, in our case that’s another QuasiEnum subtype. In this way we circumvent having to unbox object.

Another thing worth noting is that our type is reference type, so we must be aware that the incoming object may be null. This is even more important later, when we’ll override the static equality operators == and !=.

Regardless, here we have to decide, which one is better: a) to rely strictly on the actual identity of the object, or b) to rely only on its name? Well the actual C# enums rely on comparing their values, so I’m going to compare the names instead. But no hard reference match, I think that can’t work because of serialization anyway. Strictly speaking, enum values should behave like value types.

Let’s quickly introduce a method to reliably match names from wherever we need to

static public bool StringsMatch(string str1, string str2)
  => string.Equals(str1, str2, StringComparison.OrdinalIgnoreCase);

And then

public bool Equals(QuasiEnum other)
  => other is not null && StringsMatch(this.Name, other.name);

Finally, we want the method Equals to work with anything that makes sense, so matching against another QuasiEnum, another name (string), or another value (int), are all valid. If the incoming object is of the unsupported type (or null), then it’s an automatic mismatch. (The following code is known as switch expression btw.)

public override bool Equals(object obj)
  => obj switch {
      QuasiEnum other => Equals(other),
      int val => this == val,
      string str => this == str,
      {} => false
    };

“Checking against these other values, how’s that supposed to work?”

Going there, we’re doing custom static equality operators next. With them we need to make sure neither of the objects are null, and if they’re both null, the check should return true. We can test this null equality with bool ReferenceEquals(...) inherited from object. Within these operators it’s silly to attempt a == null, that would probably just lead to stack overflow.

Basically, if left-hand-side is non-null, it’s safe to proceed with Equals test we made before. And if it’s null, return true only if the right-hand-side is null as well.

static public bool operator ==(QuasiEnum a, QuasiEnum b)
  => ReferenceEquals(a, null)? ReferenceEquals(b, null) : a.Equals(b);

// strings can be null as well (even though they count as value types)
static public bool operator ==(QuasiEnum qenum, string str)
  => ReferenceEquals(qenum, null)? ReferenceEquals(str, null)
                                 : StringsMatch(qenum.Name, str);

// other value types can't be null, they have default values (i.e. 0 in this case)
static public bool operator ==(QuasiEnum qenum, int val)
  => ReferenceEquals(qenum, null)? false : qenum.Value.Equals(val);

And since == operators need to have their matching counterparts, we make sure they stay consistent

static public bool operator !=(QuasiEnum a, QuasiEnum b) => !(a == b);
static public bool operator !=(QuasiEnum qenum, string str) => !(qenum == str);
static public bool operator !=(QuasiEnum qenum, int val) => !(qenum == val);

Next up, IComparable. That’s just

public int CompareTo(QuasiEnum other) => value.CompareTo(other.Value);

and while we’re at it, let’s do GetHashCode as well

public override int GetHashCode() => GetType().GetHashCode() ^ Value.GetHashCode();

Let’s now do more pressing things, things that have to do with our static readonly item collection.
This is going to be the core method to scan the internals of our derived class and the only way to make this work is via reflection (add using System.Reflection; to the top).

static IEnumerable<FieldInfo> enumerableStaticFieldsOf<T>() where T : QuasiEnum {
  const BindingFlags flags = BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly;
  var fields = typeof(T).GetFields(flags); // fetch the list of all such fields
  if(fields is not null)
    for(int i = 0; fields.Length; i++)
      yield return fields[i];
}

I will constrain T to QuasiEnum in many such methods throughout the solution. This is so only derivations are accepted (QuasiEnum itself is abstract so you can’t instantiate that directly).

The method above will help us foreach through all static fields within the type T, when we need it. Now we can use this to create two different publicly-available methods that should work with all QuasiEnum derivations in general. The first one GetItemsOf<T> provides another IEnumerable to let us foreach through the items, and the other GetNamesOf<T> returns a fixed array of all their names. The exact order is undefined.

static public IEnumerable<T> GetItemsOf<T>() where T : QuasiEnum {
  foreach(var info in enumerableStaticFieldsOf<T>()) {
    var value = info.GetValue(null) as T;
    if(value is not null) yield return value;
  }
}

(using System.Collections.Generic;)

static public string[] GetNamesOf<T>() where T : QuasiEnum {
  var names = new List<string>();
  foreach(var info in enumerableStaticFieldsOf<T>()) {
    var value = info.GetValue(null) as T;
    if(value is not null) names.Add(value.Name);
  }
  return names.ToArray();
}

It’s almost done. Now we have the means of traversing through all internally declared items, but they must satisfy two things: 1) the type, 2) static public declaration. We should now finish the heart of the system, the way to find a specific object instance once we know its type and name (or value). I’ll write this in a LINQ-esque manner, so let’s start with the general predicate search.

static T firstMatchOrDefault<T>(IEnumerable<T> enumerable, Func<T, bool> predicate) where T : QuasiEnum {
  T item = default;
  foreach(var x in enumerable)
    if(predicate(x)) { item = x; break; }
  return item;
}

This allows us to define a robust selection method

static T selectWhere<T>(Func<T, bool> predicate) where T : QuasiEnum
  => firstMatchOrDefault(GetItemsOf<T>(), predicate);

Leading us naturally to our protected interface, to be used by user derivations

static protected T GetByName<T>(string name) where T : QuasiEnum
  => selectWhere<T>( item => StringsMatch(item.Name, name) );

static protected T GetByValue<T>(int value) where T : QuasiEnum
  => selectWhere<T>( item => item.Value == value );

Just a couple more utility functions to help us explicitly cast as well as implicitly pass our QuasiEnum objects as string arguments, and we can wrap this up.

static public T Cast<T>(QuasiEnum qenum) where T : QuasiEnum => GetByName<T>(qenum.Name);
static public T Cast<T>(string str) where T : QuasiEnum => GetByName<T>(str);
static public T Cast<T>(int value) where T : QuasiEnum => GetByValue<T>(value);

static public implicit operator string(QuasiEnum qenum) => qenum.Name;

// and let's not forget
public override string ToString() => this; // lol, now this is just showing off

Ok, so the core class is done! But there are some things which can be slightly improved. Let’s think about the user side. The user derives from QuasiEnum, declares the fields, sets the names etc. The user can write a private constructor from which it becomes very easy to build a list of all locally defined names. Thanks to the reflection code above, this isn’t mandatory, but IF the user sets this up, it would be nice if he could deliver this list, because that’s how we can completely avoid having to use reflection during run-time (edit: reflection can be slow and messy compared to regular means of searching and method invocation, especially when used in games).

So let’s change our selectWhere to

static T selectWhere<T>(Func<T, bool> predicate, IList<T> list) where T : QuasiEnum
  => firstMatchOrDefault(list?? GetItemsOf<T>(), predicate);

And expand our protected interface like so

static protected T GetByName<T>(string name, IList<T> list = null) where T : QuasiEnum
  => selectWhere<T>( item => StringsMatch(item.Name, name) , list);

static protected T GetByValue<T>(int value, IList<T> list = null) where T : QuasiEnum
  => selectWhere<T>( item => item.Value == value , list);

Let’s check what this looks like on the MyEnum side.

public class MyEnum : QuasiEnum {

  // we can now put the tools we've built so far to good use
  static public List<MyEnum> GetAll()
    => new List<MyEnum>(GetItemsOf<MyEnum>());

  static public MyEnum GetByName(string name) => GetByName<MyEnum>(name);
  static public MyEnum GetByValue(int value) => GetByValue<MyEnum>(value);

  // to mimic standard enum behavior
  static public explicit operator MyEnum(int value) => GetByValue(value);

  static public readonly MyEnum zero = new MyEnum("zero", 0);
  static public readonly MyEnum first = new MyEnum("one", 1);
  static public readonly MyEnum second = new MyEnum("two", 2);
  static public readonly MyEnum third = new MyEnum("three", 3);
  static public readonly MyEnum fourth = new MyEnum("four", 4);
  static public readonly MyEnum fifth = new MyEnum("five", 5);

  // constructor doesn't do anything at this point
  private MyEnum(string name, int value) : base(name, value) {}

}

So what has to change if MyEnum decides to support the local list to avoid having to use reflection?

public class MyEnum : QuasiEnum {

  // NOTE: this definition MUST be above the actual static readonly fields
  static List<MyEnum> _enums = new List<MyEnum>();

  // we can now change this
  static public MyEnum[] GetAll() => _enums.ToArray(); // don't forget we need a defensive copy anyway

  // and we can supply the local list where it's needed
  static public MyEnum GetByName(string name) => GetByName<MyEnum>(name, _enums);
  static public MyEnum GetByValue(int value) => GetByValue<MyEnum>(value, _enums);

  // static public readonly ...
  // static public readonly ...
  // static public readonly ...

  private MyEnum(string name, int value) : base(name, value) {
    _enums.Add(this); // they all gather nicely here
  }

}

That’s not too much work, right? Also you can derive from this class again (edit: and we will by the end of this exercise). If you’re wondering how can you inherit static fields, well, you can define the superclass, and then simply redirect all subclass queries toward its definition instead. But I haven’t played much with this (I did with the nested types, that part works as expected), the above solution was something I was happy with, and it’s perfectly maintainable the way it is.

Oh and wouldn’t it be nice if we could save some extra info, something that makes more sense than having to repeat the name of the field, like a tooltip, or a display name of sorts? Auto-generating names would also be nice to have. Because if you think about it, changing a field’s name is a huge pain in the back, because you can forget to match the two. Currently, the two are completely disjointed and MyEnum.fourth has the string value of “four” which is actually cool in a serialization context, as you can change fourth to usedToBeFourth and it will still deserialize correctly. So there are benefits to be had on both sides of the coin.

I decided that the names should match at compile-time. But here’s the problem, I don’t have any control over how and when the compiler is going to swoop through our readonlys. So the poor man’s solution was to place nameof in each instantiation.

static public readonly MyEnum second = new MyEnum(nameof(second), 2);

This way you could at least rename a field and the name would always match it, but I wasn’t really satisfied with this solution, that’s just crazy. So I played dirty instead and assumed that the most logical thing a compiler would do would be to process the whole readonly block in the most optimal order there is: top to bottom. There are no guarantees this is the case however, and this might break unexpectedly, but let’s play with it, so far it’s been very reliable.

First, we add AutoGenerateNames to QuasiEnum class

static protected T AutoGenerateNames<T>() where T : QuasiEnum {
  foreach(var info in enumerableStaticFieldsOf<T>()) {
    var value = info.GetValue(null) as T;
    if(value?.Name == "") value.Name = info.Name;
  }
  return default;
}

This way we only fill in the name if the name was already provided blank (“”). This allows the user class to define name overrides. Now we can cheese the compiler. Back to MyEnum

static public readonly MyEnum zero = new MyEnum("", 0);
static public readonly MyEnum first = new MyEnum("", 1);
static public readonly MyEnum second = new MyEnum("", 2);
static public readonly MyEnum third = new MyEnum("", 3);
static public readonly MyEnum fourth = new MyEnum("four", 4);
static public readonly MyEnum fifth = new MyEnum("", 5);
static private object _ = AutoGenerateNames<MyEnum>(); // BAM

Wholesome, isn’t it? Here I’ve deliberately set the fourth’s name to “four”.
(Edit: the way this works is overhauled in the latest version available at the end of the article. This version can also auto-define the fields, letting you avoid having to manually create objects with new)

Let’s introduce another constructor for this particular case, and then we can try and tackle extra data and future-proof some basic support for custom display names.

// back in QuasiEnum
protected QuasiEnum(int value) : this("", value) {}

This improves legibility of MyEnum

public class MyEnum : QuasiEnum {

  // ...

  static public readonly MyEnum zero = new MyEnum(0, "zero is naughty");
  static public readonly MyEnum first = new MyEnum(1, "first is straight");
  static public readonly MyEnum second = new MyEnum(2, "second is too late");
  static public readonly MyEnum third = new MyEnum(3, "third is a charm");
  static private object _ = AutoGenerateNames<MyEnum>();

  string _description;

  private MyEnum(int value, string description) : base(value) {
    _enums.Add(this);
    _description = description;
  }

  public string DisplayName => _description;

}

Ok so this is pretty extensible. The solution is now working in code and can be used as is. But we still want to support custom serialization and integrate the whole thing with Unity editors.

Serialization

Well, this part is fairly simple to implement.
Let’s add the following method to QuasiEnum class. This will allow us to examine what is being read from the file and to react accordingly.
(Also add using Debug = UnityEngine.Debug; to top.)

protected void ParseDeserialized<T>(string str, List<T> list = null, bool allowInvalid = false, bool suppressLog = false) where T : QuasiEnum {
  var parsed = GetByName<T>(str, list);

  if(parsed is not null) {
    (Name, Value) = ( parsed.Name, parsed.Value );
  } else if(allowInvalid) {
    if(!suppressLog) Debug.LogWarning($"Warning: {typeof(T).Name} had invalid deserialization data.");
    (Name, Value) = ( null, -1 );
  } else {
    throw new InvalidOperationException($"Unrecognized data '{str}'.");
  }
}

First, the user class itself has to be marked for serialization. Then it should implement ISerializationCallbackReceiver. We then introduce a serialized field called name which will be stored to YAML. This word is a reserved keyword in C# however, so we’ll have to use verbatim syntax (@). (edit: this was a mistake on my part, no it’s not reserved, everything’s fine.)

[Serializable]
public class MyEnum : QuasiEnum, ISerializationCallbackReceiver {

  // static readonly fields go here ...

  [SerializeField] string name; // must be called 'name' because the drawer relies on this

  void ISerializationCallbackReceiver.OnBeforeSerialize()
    => name = this;

  void ISerializationCallbackReceiver.OnAfterDeserialize()
    => ParseDeserialized<MyEnum>(name, list: _enums, allowInvalid: true);

  ...

SimpleSanitizer

Before we proceed with the custom property drawer, we’re going to need a utility to sanitize and beautify our auto-generated names, in a manner similar to what Unity does when it builds an auto-inspector. Namely it introduces spaces between camelCase words, capitalizes letters, and removes underscore characters. We can do that as well. I won’t explain this code too much, but in a nutshell we have a simple state tracker (ScannerState) and a simple string scanner (Process method) that runs through the string and produces a better copy of it in one go.

using System.Text;

namespace StringSanitizers {

  static public class SimpleSanitizer {

    static public string Process(string src) {
      if(string.IsNullOrWhiteSpace(src)) return src?.Trim();

      var scan = new Utils.ScannerState();

      for(int i = 1; i <= src.Length; i++) {

        scan.Sample(src[i-1], (i < src.Length)? src[i] : default);

        if(scan.curr.IsUScor()) scan.curr = Utils.SPACE;
          else scan.curr = scan.TryCaps(scan.curr);

        if(scan.curr.IsSpace()) scan.TrySpace();
          else scan.Append(scan.curr);

        if(scan.next == default) break; // no look-ahead

        if(lower_upper_pair() || any_one_digit())
          scan.TrySpace();

      }

      return scan.sb.ToString();

      bool lower_upper_pair() => scan.curr.IsLower() &&  scan.next.IsUpper();
      bool any_one_digit()    => scan.curr.IsDigit() && !scan.next.IsDigit() ||
                                !scan.curr.IsDigit() &&  scan.next.IsDigit();
    }

  }

  static class Utils {

    public const char SPACE = ' ';

    static public bool IsUScor(this char chr) => chr == '_';
    static public bool IsSpace(this char chr) => chr == Utils.SPACE;
    static public bool IsDigit(this char chr) => chr >= '0' && chr <= '9';
    static public bool IsLower(this char chr) => char.IsLetter(chr) && char.IsLower(chr);
    static public bool IsUpper(this char chr) => char.IsLetter(chr) && char.IsUpper(chr);
    static public bool IsPunct(this char chr) => char.IsPunctuation(chr);

    public class ScannerState {

      public char curr, next;

      public readonly StringBuilder sb;
      public bool shift = true;

      public ScannerState() => sb = new StringBuilder();

      public void Sample(char a, char b) => (curr, next) = (a, b);

      public void Append(char x) => sb.Append(x);

      public void TrySpace() {
        if(!shift) Append(Utils.SPACE);
        shift = true;
      }

      public char TryCaps(char x) {
        if(shift) {
          if(x != Utils.SPACE) shift = false;
          if(x.IsLower()) return char.ToUpperInvariant(x);
        }
        return x;
      }

    }

  }

}

We’ll also need these to chop some strings later, it’s just an extract from my custom extensions. In my environment they sit in their respective libraries, but I’ll have to think twice about where to put them when I dump the code in its entirety.

static public bool CropLeft(this string s, int index, out string result) {
  result = s.CropLeft(index);
  return index > 0 && index < s.Length;
}

static public string CropLeft(this string s, int index)
  => (s is null)? null : s.Substring(0, index.Clamp(s.Length + 1));

// and this is here only because I refuse to use any kind of Math lib in this solution
static int Clamp(this int v, int count) => Clamp(v, 0, count - 1);
static int Clamp(this int v, int min, int max) => (v <= max)? (v > min)? v : min : max;

(Edit: You can find all of this settled down in the latest version source code.)

Phew, now we’re ready to start untangling the property drawer.

QuasiEnumDrawer

I hate writing drawers, but this one is not that big, honestly, ~200 lines of code. I’ll go backwards, it’ll be easier to parse. First, let’s consider what features we want to implement. How about the ability to show values next to the name and/or the type of the custom enum? And what about optional support for that DisplayName getter we introduced above? I’ve also added the possibility of having a ‘default’ entry, like ‘undefined’ or ‘None’. Why should you have to add that to your enum, that’s not data, that’s a selector state? Oh oh, and sorting features, we can sort by value, or by name, or leave it unsorted.

Ok then, so let’s make a couple of enums to remind ourselves what to include. Let’s bother with how to supply them later. This is btw a legit use of standard enums in C# and Unity. You just shouldn’t describe game logic with them.

[Flags]
public enum QuasiEnumOptions {
  None = 0,
  ShowValue = 0x1,             // Shows value, in format: name (value)
  ShowTypeInLabel = 0x2,       // Shows type in label
  IncludeSelectableNone = 0x4, // Adds selectable zeroth "None" element if the list is non-empty
  UseDisplayName = 0x8,        // Uses the locally-defined DisplayName getter for display names
}

public enum QuasiEnumSorting {
  Unsorted = 0,
  ByName,
  ByValue
}

Let’s begin with the foundation.
We need this to keep track of everything, just a simple immutable struct holding what we have right now.

readonly struct RecordRow {
  public readonly string name;
  public readonly int value;
  public readonly string display;

  public RecordRow(string name, int value, string display)
    => (this.name, this.value, this.display) = (name, value, display);
}

Some general utilities we’re gonna need

// this is just to shorten this monstrosity
bool isNullOrWhite(string s) => string.IsNullOrWhiteSpace(s);

// this one will be handy, array alloc with a lambda filler
T[] newArray<T>(int len, Func<int, T> lambda) {
  var d = new T[len];
  if(lambda is not null)
    for(int i = 0; i < len; i++) d[i] = lambda(i);
  return d;
}

// finally, because we're going to use a local dictionary to help us find an index in O(1)
int indexOfRecord(string name) {
  if(!_lookup.TryGetValue(name, out var index)) index = -1;
  return index;
}

At the beginning, we want to introduce some standard strings.

static readonly string _displayName_method = "DisplayName"; // prescribed name of the getter
static readonly string _none_string = "(None)"; // for the extra 'None' selection field
static readonly string[] _empty_array = new string[] { "(Empty)" }; // when there are no names at all

We can now build our records, one row at a time, where each row represents one item. Don’t worry about that dynamic declaration, I’ll get back to it.

RecordRow buildRecordRow(dynamic item, Type type, QuasiEnumOptions options) {
  const BindingFlags flags = BindingFlags.Public | BindingFlags.GetProperty | BindingFlags.DeclaredOnly |
                 BindingFlags.FlattenHierarchy | BindingFlags.Instance; // this is to fetch that getter

  var display = string.Empty;
 
  // optionally consider the local DisplayName getter
  if(options.HasFlag(QuasiEnumOptions.UseDisplayName))
    display = type.GetProperty(_displayName_method, flags)?.GetValue(item);

  // fall back to basic name sanitation
  if(isNullOrWhite(display))
    display = SimpleSanitizer.Process(item.Name);

  // optionally append value
  if(options.HasFlag(QuasiEnumOptions.ShowValue))
    display = $"{display} ({item.Value})";

  return new RecordRow(item.Name, item.Value, display);
}

Nice. Now, the trick is to somehow fetch the values from GetNamesOf<T> method back in QuasiEnum. But this is easier said than done, because it’s a generic code that also implies covariance. Long story short, I’ve managed to properly address it, but to fully satisfy the compiler, I had to declare the result as dynamic in the end. There is probably a better solution, but I’m not bothered too much, I find this relatively appropriate for this scenario.

Here we supply a target type, and fetch back the list of names (note that we can’t just scan for readonlys again, because the user had ample opportunity to format his entries in a way that is unknown to us here).

dynamic enumerableNamesViaReflection(Type type) {
  const BindingFlags flags = BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly;
  var method = typeof(QuasiEnum).GetMethod(nameof(QuasiEnum.GetItemsOf), flags).MakeGenericMethod(type);
  return method.Invoke(null, null); // static context, parameterless
}

This was the biggest hurdle. Let’s now build the records table in its entirety.

// will return an empty array at the very least
RecordRow[] buildRecords(Type type, QuasiEnumOptions options, QuasiEnumSorting sorting) {
  var table = new List<RecordRow>();
 
  // add the rows
  foreach(var item in enumerableNamesViaReflection(type))
    table.Add(buildRecordRow(item, type, options));

  // sort the rows
  table.Sort( (a,b) => comparison(a, b, sorting) );

  // insert 'None' field
  if(options.HasFlag(QuasiEnumOptions.IncludeSelectableNone))
    table.Insert(0, new RecordRow(null, -1, _none_name)); // insert a deliberately invalid entry

  // build the index lookup after everything has settled down
  _lookup = new Dictionary<string, int>();
  for(int i = 0; i < table.Count; i++)
    _lookup[table[i].Name] = i;

  return table.ToArray(); // finished

  // just a local function to aid us with sorting
  int comparison(RecordRow a, RecordRow b, QuasiEnumSorting sorting)
    => sorting switch {
           QuasiEnumSorting.ByName => string.CompareOrdinal(a.display, b.display),
           QuasiEnumSorting.ByValue => a.value.CompareTo(b.value),
           _ => 0
        }
}

With all that done we can properly address the body of the drawer. Here I’m showing the script in full glory.

using System;
using System.Collections.Generic;
using System.Reflection;
using StringSanitizers;
using UnityEngine;
using UnityEditor;

// useForChildren is supposed to mean child classes
[CustomPropertyDrawer(typeof(QuasiEnum), useForChildren: true)]
public class QuasiEnumDrawer : PropertyDrawer {

  // we have already defined static readonly strings here

  SerializedProperty _targetProperty;

  RecordRow[] _records;            // never null (except on start)
  Dictionary<string, int> _lookup; // used for fast index-of-record lookup
  string[] _displayNames;          // cached for EditorGUI.Popup
 
  public override void OnGUI(Rect rect, SerializedProperty property, GUIContent label) {
    // settings are manually tweakable for now
    var (type, options, sorting) = ( fieldInfo.FieldType, QuasiEnumOptions.None, QuasiEnumSorting.ByValue );

    _targetProperty = property.FindPropertyRelative("name"); // this hinges on that serialized field
    if(_targetProperty is null) return; // and if that's absent we have no other option but to bail
                                       // I could handle this with more grace, maybe show something else?

    if(_records is null) // we check if this was initialized
      _records = buildRecords(type, options, sorting)

    var val = indexOfRecord(_targetProperty.stringValue);

    if(options.HasFlag(QuasiEnumOptions.ShowTypeInLabel))
      label.text = !isNullOrWhite(label.text)? $"{label.text} ({type.Name})" : type.Name;

    var savedIndent = EditorGUI.indentLevel;
    label = EditorGUI.BeginProperty(rect, label, property);
 
      rect = EditorGUI.PrefixLabel(rect, GUIUtility.GetControlID(FocusType.Passive), label);
      EditorGUI.indentLevel = 0; // after indenting label, cancel indentation entirely

      if(nonEmptyRecords(options)) {
        var newVal = drawDropDownSelector(rect, val.Clamp(_records.Length));
        if(val != newVal) _targetProperty.stringValue = _records[newVal].name;

      } else { // make the drop down appear empty and disabled
        disabledGUI( () => EditorGUI.Popup(rect, 0, _empty_array) );
        targetProperty.stringValue = string.Empty;

      }

    EditorGUI.EndProperty();
    EditorGUI.indentLevel = savedIndent;

    // let's do the local functions after this line to reduce the clutter above

    // this allows us to track states between two pieces of bread in a sandwich
    // a useful pattern that ensures minimum mess when writing IMGUI code
    void disabledGUI(Action action) {
      var saved = GUI.enabled;
      GUI.enabled = false;
      action.Invoke();
      GUI.enabled = saved;
    }

    int drawDropDownSelector(Rect rect, int selected)
      => EditorGUI.Popup(rect, selected, _displayNames??= extractDisplayColumn());

    string[] extractDisplayColumn()
      => newArray(_records.Length, i => _records[i].display );

    // if the list was empty, "None" that was optionally added shouldn't count
    bool nonEmptyRecords(QuasiEnumOptions options)
      => _records.Length - (options.HasFlag(QuasiEnumOptions.IncludeSelectableNone)? 1 : 0) > 0;

  }

  // the rest of the code we did before

}

Inspecting arrays

The solution above does not play well with serialized arrays. Well crap.
Another round then. First, we want to detect whether this property belongs to an array property, and if so, which element index it was assigned to. Both of these are another of the local functions in OnGUI. I can’t help it, they don’t belong anywhere else.

With detectArrayProperty all I do here is splice up the actual property path, sniff out the parent object, double-check it’s an array, and return it. We do this only once.

SerializedProperty detectArrayProperty() {
  if(property.name == "data") { // I.e. propertyPath = "myName.Array.data[3]"
    var path = property.propertyPath;
    if(path.CropLeft(path.LastIndexOf(".Array"), out var actualName)) { // "myName"
      var p = property.serializedObject.FindProperty(actualName);
      if(p.isArray) return p;
    }
  }
  return null;
}

We also need to get the element index. This is just a brute force search, not a pretty sight. I’m still looking into ways to improve this. But this will only encumber the inspector if the array is huge (and unfolded).

int getArrayPropertyIndex(SerializedProperty arrayProperty) {
  if(arrayProperty is null) return -1; // just a sanity check
  for(int i = 0; i < arrayProperty.arraySize; i++)
    if(SerializedProperty.EqualContents(arrayProperty.GetArrayElementAtIndex(i), property))
      return i;
  return -1;
}

Let’s also introduce mixed value display, for multi-value editing. I won’t make it work-work, but I cannot completely neglect it either. I’m not sure how Unity treats multi-value drop downs, but there is not much one can do anyway.

// yet another sandwich method
void mixedGUI(bool condition, Action action) {
  var saved = EditorGUI.showMixedValue;
  EditorGUI.showMixedValue = condition;
  action.Invoke();
  EditorGUI.showMixedValue = saved;
}

we add another SerializedProperty class field

SerializedProperty _targetProperty;
SerializedProperty _arrayProperty;

Next we go back to OnGUI

    ...

    if(_records is null) {
      _records = buildRecords(type, options, sorting);
      _arrayProperty = detectArrayProperty();
    }

    var val = indexOfRecord(_targetProperty.stringValue);

    if(_arrayProperty is not null) {
      if(string.Equals(label.text, _targetProperty.stringValue, StringComparison.InvariantCulture))
        label.text = $"Element {getArrayPropertyIndex(_arrayProperty)}";
    } else {
      if(options.HasFlag(QuasiEnumOptions.ShowTypeInLabel))
        label.text = !isNullOrWhite(label.text)? $"{label.text} ({type.Name})" : type.Name;
    }

    var savedIndent = EditorGUI.indentLevel;

    ...

      if(nonEmptyRecords(options)) {
        mixedGUI(_targetProperty.hasMultipleDifferentValues, () => {
          var newVal = drawDropDownSelector(rect, val.Clamp(_records.Length));
          if(val != newVal) _targetProperty.stringValue = _records[newVal].name;
        });

      } else { // make the drop down empty and disabled
        disabledGUI( () => EditorGUI.Popup(rect, 0, _empty_array) );
        targetProperty.stringValue = string.Empty;

      }

  ...

Edit: The drawer was changed slightly in the latest version. Most of this still holds true however.

Custom Attribute

Lastly, we ought to introduce a custom attribute. This will allow us to supply the missing options (as metadata).

using System;
using UnityEngine;

[AttributeUsage(AttributeTargets.Field, Inherited = true, AllowMultiple = true)]
public class QuasiEnumAttribute : PropertyAttribute {
 
  public readonly Type type;
  public readonly QuasiEnumOptions options;
  public readonly QuasiEnumSorting sorting;

  public QuasiEnumAttribute(Type type)
    : this(type, QuasiEnumOptions.None, QuasiEnumSorting.ByValue) {}

  public QuasiEnumAttribute(Type type, QuasiEnumSorting sorting)
    : this(type, QuasiEnumOptions.None, sorting) {}

  public QuasiEnumAttribute(Type type, QuasiEnumOptions options, QuasiEnumSorting.sorting) {
    if(!type.IsSubclassOf(typeof(QuasiEnum))
      throw new ArgumentException($"{nameof(QuasiEnumAttribute)} works only with subtypes of {nameof(QuasiEnum)}.");

    (this.type, this.options, this.sorting) = (type, options, sorting);
  }
}

This lets us fully describe the MyEnum field in our production code.
For example

using System;
using UnityEngine;

public class QuasiEnumTest : MonoBehaviour {

  [SerializeField]
  [QuasiEnum(typeof(MyEnum), QuasiEnumOptions.ShowValue | QuasiEnumOptions.IncludeSelectableNone, QuasiEnumSorting.ByName)]
  MyEnum _enum; // try also MyEnum[] _enum;

}

But we do have to register this data in our drawer to make this work.

[CustomPropertyDrawer(typeof(QuasiEnumAttribute))]
[CustomPropertyDrawer(typeof(QuasiEnum), useForChildren: true)]
public class QuasiEnumDrawer : PropertyDrawer {

  ...

  public override void OnGUI(Rect rect, SerializedProperty property, GUIContent label) {
    var attrib = this.attribute as QuasiEnumAttribute;
 
    var (type, options, sorting) = (attrib is null)? ( fieldInfo.FieldType, QuasiEnumOptions.None, QuasiEnumSorting.ByValue )
                                                   : ( attrib.type, attrib.options, attrib.sorting );

    ...

  }

  ...

}

Edit: In the latest version, the attribute does not work on the actual field anymore, but on the class instead. This is because I’ve added settings to how the type itself is treated (for example a setting to auto-define the fields etc). There is also a possibility I will make two separate attributes in the final version, because you might want to have two serialized fields for the same type, one that accepts ‘None’ and the other that doesn’t, as an example. I’m still thinking about it.

Tooltips

As a bonus let’s add support for tooltips as well. We can start by attacking the underlying data.

[Flags]
public enum QuasiEnumOptions {
  None = 0,
  ShowValue = 0x1,             // Shows value, in format: name (value)
  ShowTypeInLabel = 0x2,       // Shows type in label
  IncludeSelectableNone = 0x4, // Adds selectable zeroth "None" element if the list is non-empty
  UseDisplayName = 0x8,        // Uses the locally-defined DisplayName getter for display names
  UseTooltipString = 0x10      // Uses the locally-defined TooltipString getter for item tooltips
}
RecordRow buildRecordRow(dynamic item, Type type, QuasiEnumOptions options) {

  var display = string.Empty;
  var tooltip = default(string);
  ...

  // optionally consider the local DisplayName getter
  if(options.HasFlag(QuasiEnumOptions.UseDisplayName))
    display = type.GetProperty(_displayName_method, flags)?.GetValue(item);

  // optionally consider the local TooltipString getter
  if(options.HasFlag(QuasiEnumOptions.UseTooltipString)) // new flag
    tooltip = type.GetProperty(_tooltipName_method, flags)?.GetValue(item); // new getter
  ...

  return new RecordRow(item.Name, item.Value, display, tooltip); // modified c-tor
}
readonly struct RecordRow {
  public readonly string name;
  public readonly int value;
  public readonly string display;
  public readonly string tooltip; // new field

  public RecordRow(string name, int value, string display, string tooltip = null)
    => (this.name, this.value, this.display, this.tooltip) = (name, value, display, tooltip);
}

Then,

...

public class QuasiEnumDrawer : PropertyDrawer {

  static readonly string _displayName_method = "DisplayName";
  static readonly string _tooltipName_method = "TooltipString";

  ...

  RecordRow[] _records;            // never null (except on start)
  Dictionary<string, int> _lookup; // used for fast index-of-record lookup
  string[] _displayNames;          // cached for EditorGUI.Popup
  GUIContent[] _guiContent;        // same as _displayNames, but for tooltips

  public override void OnGUI(Rect rect, SerializedProperty property, GUIContent label) {
    ...
    var newVal = drawDropDownSelector(rect, val.Clamp(_records.Length), options.HasFlag(QuasiEnumOptions.UseTooltipString));
    ...

    int drawDropDownSelector(Rect rect, int selected, bool usesTooltips)
      => !usesTooltips? EditorGUI.Popup(rect, selected, _displayNames??= extractDisplayColumn())
                      : EditorGUI.Popup(rect, GUIContent.none, selected, _guiContent??= extractGUIContent());

    ...

    GUIContent[] extractGUIContent() // I told you newArray was handy
      => newArray(_records.Length, i => new GUIContent(_records[i].display, _records[i].tooltip) );
    ...
  }
  ...

}

And then we can modify MyEnum slightly

[Serializable]
public class MyEnum : QuasiEnum, ISerializationCallbackReceiver {

  // this definition MUST be above the actual static readonly fields
  static List<MyEnum> _enums = new List<MyEnum>();
  static public MyEnum[] GetAll() => _enums.ToArray();

  static public MyEnum GetByName(string name) => GetByName<MyEnum>(name, _enums);
  static public MyEnum GetByValue(int value) => GetByValue<MyEnum>(value, _enums);

  static public explicit operator MyEnum(int value) => GetByValue(value);

  static public readonly MyEnum zero = new MyEnum(0, "zero is naughty");
  static public readonly MyEnum first = new MyEnum(1, "first is straight");
  static public readonly MyEnum second = new MyEnum(2, "second is too late");
  static public readonly MyEnum third = new MyEnum(3, "third is a charm");
  static public readonly MyEnum fourth = new MyEnum(4, "fourth has quad damage");
  static public readonly MyEnum fifth = new MyEnum(5, "fifth is a wheel");
  static public readonly MyEnum sixth = new MyEnum(6, "sixth is a sense");
  static public readonly MyEnum seventh = new MyEnum(7, "seventh is a seal");
  static public readonly MyEnum eight = new MyEnum(8, "ate a pizza");
  static public readonly MyEnum nine = new MyEnum(9, "nine is the captain of the bunch");
  static private object _ = AutoGenerateNames<MyEnum>();

  [SerializeField] string @name; // must be called @name

  void ISerializationCallbackReceiver.OnBeforeSerialize()
    => @name = this;

  void ISerializationCallbackReceiver.OnAfterDeserialize()
    => ParseDeserialized<MyEnum>(@name, list: _enums, allowInvalid: true);

  string _description;

  private MyEnum(int value, string description) : base(value) {
    _enums.Add(this);
    _description = StringSanitizers.SimpleSanitizer.Process(description);
  }

  public string DisplayName => _description;
  public string TooltipString => $"Hint: {_description}";

}

Feedback and questions are welcome.

Edit:
I’ve redacted the text above to reflect the latest version, where it mattered, and fixed some typos etc. The solution has changed dramatically in the meantime, however not by changing what was already explained, but by promoting the user class into yet another type-wrapper. This allowed me to do a second round of features, such as field auto-define. Then I decided to rename the whole thing to ErsatzEnumeration.

You can download the latest version of ErsatzEnum (in source code) in post #12

4 Likes

Thanks for taking the time to share this! There’s a bunch of useful functionality included here.

One idea that comes to mind reading through this, is that the amount of boilerplate could be reduced even more (and some additional functionality baked in, such as implicit conversion operators) if a source generator was used.

With this approach it could be possible to write just this:

public sealed partial class MyEnum : QuasiEnum
{
    static public readonly MyEnum duck = 1;
    static public readonly MyEnum beaver;
    static public readonly MyEnum rabbit;
}

And then the rest of the functionality that can’t be in the base class could be auto-generated in a second partial class.

public partial class MyEnum
#if UNITY_EDITOR
: ISerializationCallbackReceiver
#endif
{
    static public readonly MyEnum none = new MyEnum(nameof(none));

    private MyEnum(string name) : base(name) { }

    public static implicit operator int(MyEnum myEnum)
    {
        return myEnum?.name switch
        {
            nameof(none) => 0,
            nameof(duck) => 1,
            nameof(beaver) => 2,
            nameof(rabbit) => 3,
            _ => -1
        };
    }

    public static implicit operator string(MyEnum myEnum) => myEnum?.name;

    public static IEnumerable<MyEnum> All
    {
        get
        {
            yield return duck;
            yield return beaver;
            yield return rabbit;
        }
    }
 
    static MyEnum()
    {
        beaver = new MyEnum(nameof(beaver));
        rabbit = new MyEnum(nameof(rabbit));
    }

    public static implicit operator MyEnum(int value)
    {
        return value switch
        {
            0 => none ?? new MyEnum(nameof(none)),
            1 => duck ?? new MyEnum(nameof(duck)),
            2 => beaver ?? new MyEnum(nameof(beaver)),
            3 => rabbit ?? new MyEnum(nameof(rabbit)),
            _ => null
        };
    }

    public static implicit operator MyEnum(string name)
    {
        return name switch
        {
            nameof(none) => none ?? new MyEnum(nameof(none)),
            nameof(duck) => duck ?? new MyEnum(nameof(duck)),
            nameof(beaver) => beaver ?? new MyEnum(nameof(beaver)),
            nameof(rabbit) => rabbit ?? new MyEnum(nameof(rabbit)),
            _ => null
        };
    }

    #if UNITY_EDITOR
    void ISerializationCallbackReceiver.OnBeforeSerialize()
    {
        switch(name)
        {
            case nameof(none):
            case nameof(duck):
            case nameof(beaver):
            case nameof(rabbit):
                return;
            default:
                Debug.LogError($"Invalid serialized MyEnum value detected: {name}.");
                return;
        }
    }

    void ISerializationCallbackReceiver.OnAfterDeserialize()
    {
        switch(name)
        {
            case nameof(none):
            case nameof(duck):
            case nameof(beaver):
            case nameof(rabbit):
                return;
            default:
                Debug.LogError($"Invalid serialized MyEnum value detected: {name}.");
                return;
        }
    }
    #endif
}

Outside of this, it should also be possible to get rid of the need to manually assign names or values to the members (or call AutoGenerateNames) by using reflection to inject default values to fields that have a null value.

#if UNITY_EDITOR
[InitializeOnLoad]
#endif
[Serializable]
public abstract class QuasiEnum
{
    [SerializeField]
    private string name;
    [SerializeField]
    private int value;

    protected QuasiEnum() { } // removes the need to define a constructor in derived classes

    protected QuasiEnum(string name, int value)
    {
        this.name = name;
        this.value = value;
    }

    protected QuasiEnum(int value)
    {
        name = null;
        this.value = value;
    }

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
    private static void EnsureStaticConstructorIsExecuted() { }

    static QuasiEnum()
    {
        foreach(var type in GetDerivedTypes())
        {
            int nextUnderlyingValue = 0;

            var fields = GetPublicStaticFields(type);
            HashSet<int> existingUnderlyingValues = new HashSet<int>();
            foreach(var field in GetPublicStaticFields(type))
            {
                if(field.GetValue(null) is QuasiEnum value)
                {
                    existingUnderlyingValues.Add(value.value);
                }
            }

            foreach(var field in GetPublicStaticFields(type))
            {
                if(!typeof(QuasiEnum).IsAssignableFrom(field.FieldType))
                {
                    continue;
                }

                var value = field.GetValue(null) as QuasiEnum;
                bool isNull = value is null;
                bool isMissingName = !isNull && string.IsNullOrEmpty(value.name);

                if(!isNull && !isMissingName)
                {
                    continue;
                }

                var setValue = FormatterServices.GetUninitializedObject(type);
                typeof(QuasiEnum).GetField(nameof(name), BindingFlags.NonPublic | BindingFlags.Instance).SetValue(setValue, field.Name);

                if(isNull)
                {
                    typeof(QuasiEnum).GetField(nameof(value), BindingFlags.NonPublic | BindingFlags.Instance).SetValue(setValue, nextUnderlyingValue);
                    do
                    {
                        nextUnderlyingValue++;
                    }
                    while(!existingUnderlyingValues.Add(nextUnderlyingValue));
                }
                else
                {
                    typeof(QuasiEnum).GetField(nameof(value), BindingFlags.NonPublic | BindingFlags.Instance).SetValue(setValue, field.GetValue(null));
                }
           
                field.SetValue(null, setValue);
            }
        }

        static IEnumerable<Type> GetDerivedTypes()
        {
            foreach(var assembly in AppDomain.CurrentDomain.GetAssemblies())
            {
                foreach(var type in assembly.GetTypes())
                {
                    if(typeof(QuasiEnum).IsAssignableFrom(type) && type != typeof(QuasiEnum))
                    {
                         yield return type;
                    }
                }
            }
        }
 
        static IEnumerable<FieldInfo> GetPublicStaticFields(Type type)
        {
            return type.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly);
        }
    }
}

Usage:

public sealed class MyEnum : QuasiEnum
{
    static public readonly MyEnum none;
    static public readonly MyEnum duck;
    static public readonly MyEnum beaver;
    static public readonly MyEnum rabbit;
}

Test:

[MenuItem("Test/Test MyEnum")]
public static void Test()
{
    Debug.Log(MyEnum.none.name + " : " + MyEnum.none.value); // prints "none : 0"
    Debug.Log(MyEnum.duck.name + " : " + MyEnum.duck.value); // prints "duck : 1"
    Debug.Log(MyEnum.beaver.name + " : " + MyEnum.beaver.value); // prints "beaver : 2"
    Debug.Log(MyEnum.rabbit.name + " : " + MyEnum.rabbit.value); // prints "rabbit : 3"
}
1 Like

Thanks for replying! That’s definitely a different and interesting approach.

I kind of don’t really like or use generators, because in my experience they massacre the code base and transplant the programmer from the position of parsing it into some sort of abstract heavens, where things like race condition become a commonplace (you’re more like a dispatcher, observing a never-ending train routing panel, where the train collisions are inevitable). I’ve been in a position of maintaining a code that was generated by some very expensive tool and was thoroughly disappointed with how little anybody understood the underlying system, and this produced a feedback loop on the system.

And it’s not that I believe the programmers should be exclusively crunching lines of code, but that the tools are typically so messy and obtuse to the point of actually sacrificing productivity and optimality of the solution. I.e. nobody is ever going to maintain the generated code (not even the generator which is just absurd) and this pretty much escalates into an effect similar to ‘Phantom traffic jam’ (aka traffic wave), where you get anomalous perturbances in the system without anyone being responsible.

Ok that’s the explanation why I don’t go down this route, however, in this particular case, enums are pretty static anyway, and the extended functionality is pretty straightforward (and a ‘leaf’ in a code-base diagram, so to speak). So the questions are

  • what would be the cons of this kind of automation?
  • what would you use to auto-generate the code?
  • and would the standard enum be as pragmatic and ubiquitous if it came with a generator? (I mean arguably it already does, but I mean as a tool external to standard C# workflow.)

Even though your initial class is super clean, there is a certain connotational burden.

In my case, what you see is what you get, and I intend to lean it down further as much as I can, not so much so that it resembles the simplicity of your example – I doubt that’s achievable without some extra work like you’ve shown – but so that it becomes harder to introduce an oversight or forget about the moving parts, both of which can cause fatigue in decision making.

You know, I guess what I’m trying to say, what stops anyone from doing just “MyEnum > beaver, duck, possum” in a text file, and then run the automation suite? But this is not what solves the initial problem anymore. It has definitely lost the hands-on approach and the feelings of engagement and responsibility. I didn’t even know how much I was weighing a machine-driven solution based on parameters that have nothing to do with the actual machines, so thanks for that.

In fewer words, I guess what I’m delineating is “system” (state) vs “engineering” (process). I am not after the system per se. As I said “MyEnum > beaver, duck, possum” would be the best approach in the universe, because you then ultimately don’t care about the implementation details at all.

Yeah, it’s a valid concern that it can be more difficult to make alterations to code created by source generators (especially on a per-class basis).

Maybe partially as a consequence of being an asset store dev, I tend to optimize heavily for the simplicity of the day-to-day developer experience, trying to remove as much friction as possible by pushing complexity down away from the most user-facing public APIs. But I hear you that on the flip side making things too simple can also serve to take people further away from what made them fall in love with programming in the first place :slight_smile:

This discussion reminds me of the record type a little bit; one could make the argument that it can help one write more elegant and expressive code, with less noise - but on the flip side the final C# code that gets generated is very ugly, and no simpler for the computer to execute in the end than if the developer had simply written that boilerplate themselves.

1 Like

This is a very interesting discussion.

I’m not talking about personal preferences or impressions here, though I do generally seem to people like an emotional type (I probably am). But it’s not about me being attached to certain aspects of programming (even though I am), it’s about the dichotomy between the craft and the industry, and this is a very intense anthropological subject.

You see, if we talk about mining, for example, we can all differentiate between a miner and a mining operation. To exaggerate a bit, the miner can’t do much and is more likely to get hurt at some point, while the mining operation is a shapeless endeavor that can disintegrate a mountain. But it’s not without it costs, which can be enormous, not to mention the social and ecological footprint. So the crux of the issue is at which point in time should the miner become a mining operation? And is there an inflection point in this continuum?

It turns out that all human endeavors suffer from this apparent linearity where some singular interest, activity, or motion is capable of insurrecting the whole of human race into a bustle which can only be described as “business” or “economy”. Once that happens, the notion of management is inevitable. It is that surplus activity that perfectly delineates craft from an industry, along with the standardization.

However, the economy of scale must not preclude the premature individual process, but since the individual process is constantly evolving, the inflection point becomes a question of strategy, to minimize the costs and streamline the management toward some measurable goal. I think this is why we “need” politics, basically. And I think at this point you can see where and why I draw the parallel between mining and programming.

Likewise, a developed industrial capacity does not eliminate the need for perfecting the individual process. It actually depends on it. The nature of a problem doesn’t magically go away once you encourage the whole country to do it (but it’s harder for an individual to consider oneself as capable of introducing a change). And this is why we should never constrain ourselves by that which only a management would approve.

In fact, I would go as far to claim that individually we should strive to do exactly the opposite, as this is the only way to increase the cost of the inertial systems that have already scaled wastefully. In that sense I firmly believe that auto-generated code not only does not help the individual, but increases the tech debt to the point where the individual is losing, and the establishment is “winning” (for a short while before the inevitable crash ensues due to interconnected feedback loops in every such economy of scale).

I’m not sure if you (or anyone for that matter) can follow my philosophical tirades, but thanks for the involvement. Anyway, your example inspired me in a couple of surprising ways and this is what I have at the moment.

using System;
using UnityEngine;

public class ErsatzEnumTest : MonoBehaviour {

  [SerializeField]
  [ErsatzEnum(ErsatzEnumOptions.ShowValue | ErsatzEnumOptions.IncludeSelectableNone | ErsatzEnumOptions.UseTooltipString, ErsatzEnumSorting.ByValue)]
  MyEnum[] _enum;

  [Serializable] private sealed class MyEnum : ErsatzEnum<MyEnum> {

    static public explicit operator MyEnum(int value) => GetByValue(value);

    //-----------------------------------------------------

    static public readonly MyEnum zero = new MyEnum(0, "zero is naughty");
    static public readonly MyEnum first = new MyEnum(1, "first is straight");
    static public readonly MyEnum second = new MyEnum(2, "second is too late");
    static public readonly MyEnum third = new MyEnum(3, "third is a charm");
    static public readonly MyEnum fourth = new MyEnum(4, "fourth has quad damage");
    static public readonly MyEnum fifth = new MyEnum(5, "fifth is a wheel");
    static public readonly MyEnum sixth = new MyEnum(6, "sixth is a sense");
    static public readonly MyEnum seventh = new MyEnum(7, "seventh is a seal");
    static public readonly MyEnum eight = new MyEnum(8, "ate a pizza");
    static public readonly MyEnum nine = new MyEnum(9, "nine is the captain of the bunch");

    //-----------------------------------------------------

    static MyEnum() => ErsatzEnum<MyEnum>.DeserializationSettings(allowNone: true);

    string _desc;

    private MyEnum(int value, string desc) : base(value) {
      _desc = StringSanitizers.SimpleSanitizer.Process(desc);
    }

    //-----------------------------------------------------

    public string DisplayName => _desc;
    public string TooltipString => $"Hint: {_desc}";

  }

}

The solution was to introduce an intermediate class that is aware of the user-type. I’ve also cleaned up the project thoroughly and renamed it to ErsatzEnum. Still working on it but I think it’s beautiful. As soon as I stress test this a little more, I will dump the full code and update the guide above.

This is the intermediate class

using System;
using System.Collections.Generic;
using UnityEngine;

[Serializable]
public abstract class ErsatzEnum<T> : ErsatzEnumCore, ISerializationCallbackReceiver where T : ErsatzEnumCore {

  static List<T> _list = new List<T>();

  static public T[] GetAll() => _list.ToArray();

  static public T GetByName(string name)
    => GetByName<T>(name, _list);

  static public T GetByValue(int value)
    => GetByValue<T>(value, _list);

  static object _ = AutoGenerateNames<T>();

  static bool _allowNone, _allowInvalid, _suppressLog;

  static public void DeserializationSettings(bool allowNone = true, bool allowInvalid = false, bool suppressLog = false)
    => (_allowNone, _allowInvalid, _suppressLog) = (allowNone, allowInvalid, suppressLog);

  [SerializeField] string name;

  void ISerializationCallbackReceiver.OnBeforeSerialize()
    => name = this;

  void ISerializationCallbackReceiver.OnAfterDeserialize()
    => ParseDeserialized<T>(name, list: _list, _allowNone, _allowInvalid, _suppressLog);

  protected ErsatzEnum(int value) : base(value)
    => _list.Add(this as T);

}
1 Like

Glad to hear you were perhaps able to extract some value from my wandering throughs!

I think I get you. Cost–benefit analysis, premature optimization, overengineering, technical debt, KISS, YAGNI. And re-traumatization avoidance :stuck_out_tongue:

1 Like

when paradigms are agile patterns are solid

I am proud to report that this now works

[Serializable]
class MyEnum : ErsatzEnum<MyEnum> {

  static public readonly MyEnum duck;
  static public readonly MyEnum rabbit;
  static public readonly MyEnum beaver;
  static public readonly MyEnum _porcupine;

  private MyEnum(string name, int value) : base(name, value) {}

}

This produces the following drop-down

Duck (0)
Rabbit (1)
Beaver (2)
Porcupine (3)

You can still override each element individually, and decorate it with tooltips and custom display names.
Adding a defined element to an undefined list, will auto-increment the values, much like standard enum.

This

[Serializable]
class MyEnum : ErsatzEnum<MyEnum> {

  static public readonly MyEnum duck;
  static public readonly MyEnum rabbit;
  static public readonly MyEnum beaver;
  static public readonly MyEnum possum = new MyEnum(8);
  static public readonly MyEnum _porcupine;

  private MyEnum(int value) : base(value) {}
  private MyEnum(string name, int value) : base(name, value) {}

}

produces

Duck (0)
Rabbit (1)
Beaver (2)
Possum (8)
Porcupine (9)

However, all fields are serialized as strings so there is no headache when adding or removing.
This is an excerpt from the scene YAML (serialized array)

_enum:
- name: rabbit
- name: beaver
- name: '*'
- name: _porcupine

‘*’ is now a special signal when the drawer is allowed to produce ‘None’ as a pseudo-identity.

The system will also analyze the fields and notice a missing ‘readonly’ (or a wrong type) and I’m currently working on registering duplicates (which can happen when the names are all manually set). The names can be decoupled from the in-code field names (if that’s desirable) and the automatic name generation is still in place if a name is set to empty (“”) on purpose.

All that’s left is to make an attribute for the class itself, because there are 5 different settings now:

  • auto-define (on start)
  • auto-name (on start)
  • allow none (when deserializing)
  • allow invalid (when deserializing)
  • suppress log (when invalid)

Right now I’m pulling them at the right moment, which is before assembly gets fully initialized, and because I have zero control over this I don’t get to choose the aesthetics of this solution, so it’s just a brick-looking method with five flags that you have to write if you want to change something. And I’m thinking the attribute will be more elegant, and at disposal.

The only caveat I’ve discovered so far is that it mustn’t have a parameterless constructor. This, for some reason, demolishes the entire system. It’s very weird. I’m guessing it’s serialization, but who knows.

Awesome job! :sunglasses: Glad to see that you were able to get rid of the need to call AutoGenerateNames. This looks super user-friendly now.

The reason why adding a protected parameterless constructor to the base class beaks everything is that Activator.CreateInstance (I’m assuming that is what you’re using) can no longer find a constructor to invoke with reflection in the derived classes, because constructors aren’t inherited from base classes. FormatterServices.GetUninitializedObject can be used to create an instance instead, and then field, property or method injection can be used to initialize it.

1 Like

Indeed, that’s what I thought as well! But then after a series of experiments I concluded it was a relative mystery because just a mere existence of a parameterless c-tor would break everything even if I didn’t use it (i.e. if I remove reflection calls completely). Anyway I switched to InvokeMember instead of CreateInstance. Thanks for the tip, I’ll check out GetUninitializedObject pronto.

The main issue with this was how to access the protected c-tor from the base class (it sounds wrong anyway, but really made sense in this case). But if I can inject a method, that’s even better.

Now the attribute is on the class itself. I reuse the settings both in the initialization and for the drawer.

1 Like

hahaha GetUninitializedObject worked perfectly.
So perfectly in fact that it completely circumvented the intended c-tor behavior :slight_smile:
Namely the internal list registration, I forgot about that quirk, so annoying.

Anyway this has prompted me to find a way around it, and I’ve discovered that the base class can’t see a protected member of its subclass (the type of which it knows, and its base type is itself). I probably just can wrap my head around it, but it sounds so stupid on paper.

In any case I moved away from that solution entirely, and changed the way I’m registering the entries altogether, because I had to ensure (manual) names weren’t duplicated regardless (especially when mixed with auto-define). And now you don’t even need to have a default c-tor in the user class for all of this to work. Thanks for that, that part was really bothering me.

Here’s an example

[ErsatzEnum(ErsatzEnumSetting.AutoDefineFields)]
[Serializable] class MyEnum : ErsatzEnum<MyEnum> {

  static public readonly MyEnum duck;
  static public readonly MyEnum rabbit;
  static public readonly MyEnum beaver;
  static public readonly MyEnum possum = new MyEnum(5);
  static public readonly MyEnum _porcupine;

  private MyEnum(int value) : base(value) {}

}
1 Like

The source can be downloaded from here (v1.0).
(I’m not a GitHub user and the source code is just zipped for convenience.)

It consists of five files:

  • Attributes/ErsatzEnumAttribute.cs (doesn’t work on the serialized field anymore)
  • Editor/ErsatzEnumDrawer.cs (QuasiEnumDrawer but overhauled)
  • ErsatzEnum.cs (introduced as a type-wrapper class)
  • ErsatzEnumCore.cs (what used to be called QuasiEnum, but expanded)
  • StringSanitizers.cs (as shown in the guide)

Usage should be pretty self-explanatory (with the snippets scattered all over this thread), and if not I’ll make sure to add more examples.

Because it’s too early to tell, the thing might contain bugs.
If anyone encounters any bugs or weird behavior, tell me here.
There is one known issue: User class must not contain a constructor without parameters.
That would be this code

private MyEnum() : base("", 0) {} // no arguments = malfunction

Features I’ve covered so far:

  • validation of public static fields (must be readonly for example)
  • name duplication check (when you set your names manually)
  • automatic name definition and auto-incrementing integer values (when AutoDefine is on)
  • back-stage auto-filling when names are deliberately set to empty strings (“”)
  • fully operational custom drawer for Unity inspectors (you can make custom editors with it)
  • support for custom display names and tooltips as well as basic string sanitation
  • serialization by name, not by value, allowing you to freely add or remove entries without corrupting everything
  • allowing for ‘None’ values in drawer (and some output customization, like sorting)
  • handling issues with deserialization
  • public domain do-whatever-you-want license

The attribute in this version is used on the class itself, not on your serialized variable.

Here’s one example use scenario for when you do allow for ‘None’ as a state identity.

[ErsatzEnum(ErsatzEnumOptions.DisplayAndTooltip | ErsatzEnumOptions.IncludeSelectableNone,
            ErsatzEnumSorting.ByValue, ErsatzEnumSetting.AllowNone)]

[Serializable] public class MyEnum : ErsatzEnum<MyEnum> {

  // this allows you to check for this special case throughout your code
  static public bool IsNone(MyEnum @enum) => ErsatzEnum<MyEnum>.IsNone(@enum);

  // this allows you to cast your custom enums to values in a typical manner, i.e. (MyEnum)8
  static public explicit operator MyEnum(int value) => GetByValue(value);
  // this will return null if the value does not exist, and will return only the first enum if multiple were found

  // the other way around works by virtue of the core class, i.e. (int)MyEnum.fifth
  // trying to extract a value from an undefined enum will result in a crash, for safety
  // trying to extract a string from an undefined enum will return 'null'

  // because we set these values these fields do not count as "undefined"
  // and will simply auto-fill their names
  static public readonly MyEnum zero = new MyEnum(0, "zero is naughty");
  static public readonly MyEnum first = new MyEnum(1, "first is straight");
  static public readonly MyEnum fourth = new MyEnum(4, "fourth has quad damage");
  static public readonly MyEnum third = new MyEnum(3, "third is a charm");
  static public readonly MyEnum second = new MyEnum(2, "second is too late");
  static public readonly MyEnum fifth = new MyEnum(5, "fifth is a wheel");
  static public readonly MyEnum sixth = new MyEnum(6, "sixth is a sense");
  static public readonly MyEnum seventh = new MyEnum(7, "seventh is a seal");
  static public readonly MyEnum eighth = new MyEnum(8, "ate a pizza");
  static public readonly MyEnum ninth = new MyEnum(9, "nine is the captain of the bunch");

  // You must avoid parameterless constructor as it will break everything!

  string _desc;

  // Example of a user-set constructor: just a value and custom arg
  private MyEnum(int value, string desc) : base(value) // the name is internally set as ""
    => _desc = StringSanitizers.SimpleSanitizer.Process(desc).Trim();

  // custom display name and tooltip
  public string DisplayName => _desc;
  public string TooltipString => $"Tooltip: {_desc}";

}

All names are case-insensitive.
Do your naming according to your standards, the above code is just a test example.

Check out the previous (edit: or next) comment for another usage example with undefined fields.

Another example with AutoDefine and ‘None’

using System;
using UnityEngine;
using ErsatzEnumeration;

// this is so we can access the fields in this test
// without having to begin with MyEnum. all the time
// completely non-mandatory
using static ErsatzEnumTest.MyEnum;

// same thing for Debug
using static UnityEngine.Debug;

[ExecuteInEditMode]
public class ErsatzEnumTest : MonoBehaviour {

  [SerializeField] MyEnum _single;
  [SerializeField] MyEnum[] _array;

  void OnValidate() {
    Log($"duck {duck}"); // duck
    Log($"rabbit.Value {rabbit.Value}"); // 1
    Log($"(int)possum {(int)possum}"); // 5
    Log($"(int)(MyEnum)_porcupine.Name {(int)(MyEnum)_porcupine.Name}"); // 6
    Log($"value of {(MyEnum)2} is {((MyEnum)2).Value}"); // value of beaver is 2
    //Log($"value of {(MyEnum)3} is {((MyEnum)3).Value}"); // would produce an error
    Log($@"(MyEnum)""duck"" {(MyEnum)"duck"}"); // duck
    Log($@"(MyEnum)""giraffe"" == null {(MyEnum)"giraffe" == null}"); // True
    Log(possum != beaver); // True
    Log(possum == possum); // True (also produces a compiler warning)
    Log(possum.CompareTo(_porcupine)); // -1
    Log("---");
    Log($"_single == rabbit {_single == rabbit}");
    Log($"_single.IsNone() {_single.IsNone()}");
    if(_array.Length >= 2) Log($"_array[1] {_array[1]}");
    if(_array.Length >= 3) Log($@"(string)_array[2]+""!"" {(string)_array[2]+"!"}");
    if(_array.Length >= 3) Log($"_array[2].AsValidOr(duck) {_array[2].AsValidOr(duck)}");
  }

  [ErsatzEnum(ErsatzEnumOptions.IncludeSelectableNone,
     ErsatzEnumSorting.ByName, ErsatzEnumSetting.AutoDefineFields | ErsatzEnumSetting.AllowNone)]

  [Serializable] public sealed class MyEnum : ErsatzEnum<MyEnum> {

    // i.e. _single.IsNone()
    public bool IsNone() => ErsatzEnum<MyEnum>.IsNone(this);

    // as a convenience when handling None-friendly enums
    public MyEnum AsValidOr(MyEnum dflt) => this.IsNone()? dflt : this;

    // optional static test (so you can also MyEnum.IsNone(_single))
    static bool IsNone(MyEnum en) => ErsatzEnum<MyEnum>.IsNone(en);

    static public explicit operator MyEnum(string name) => GetByName(name);
    static public explicit operator MyEnum(int value) => GetByValue(value);

    static public readonly MyEnum duck; // implicitly 0
    static public readonly MyEnum rabbit; // implicitly 1
    static public readonly MyEnum beaver; // implicitly 2
    static public readonly MyEnum possum = new MyEnum(5); // explicitly 5
    static public readonly MyEnum _porcupine; // implicitly 6

    private MyEnum(int value) : base(value) {}

  }
}
1 Like

I went through the tut once again, fixed some typos etc.
Also wanted to say thanks to @SisusCo . The solution turned out great thanks to his input.

If you want enums that serialize as strings, here’s a minimal use case example (if the more complicated ones scare you away).

using ErsatzEnumeration;

[ErsatzEnum(ErsatzEnumSetting.AutoDefineFields)]
public sealed class MyEnum : ErsatzEnum<MyEnum> {

  static public readonly MyEnum DireBear;
  static public readonly MyEnum WereWolf;
  static public readonly MyEnum IRSTroll;
  static public readonly MyEnum VagabondHobbit;

}

Download button is two posts above.

You got it, I’m glad I was of some help :slight_smile:

1 Like

Btw does anyone has ANY idea how to find an index of a serialized array differently?
(And for that matter how to manage the serialized array properly, but that’s probably a wider topic.)

I’ve included two methods in my solution, because even though the first one is relatively proper, it is O(n), which mandates the other method, one that slices the actual property path (and parses to int), and that seems to be the only source of this information. It’s not that this method is slow, but I HATE IT.

// p is the leaf node i.e. "parent.someProperty.Array.data[2]"
// arrayProperty is the actual "parent.someProperty", isolated in an earlier step
int getArrayPropertyIndex(SerializedProperty p, SerializedProperty arrayProperty) {
  if(arrayProperty is null) return -1;
  if(arrayProperty.arraySize < 16) { // method #1 for small arrays
    for(int i = 0; i < arrayProperty.arraySize; i++)
      if(SerializedProperty.EqualContents(arrayProperty.GetArrayElementAtIndex(i), p))
        return i;
  } else { // method #2 for big arrays
    var path = p.propertyPath;
    var part = path.LastIndexOf('[') + 1;
    return int.Parse(path.Substring(part, path.Length - part - 1));
  }
  return -1;
}

Both me and the rest of the world would be very grateful if there is a better solution (I don’t mind if it’s reflection-based). Apparently nobody at Unity thinks such a core serialization API thing should be exposed for the last 10 years or so.