Type Alias

Why you might want to use a type alias in C# instead of an enum.

Tagged with: csharp, programming

Published on

While working on projects sometimes the need to add a thing that behaves like a primitive type but only allows a defined set of values. Many people would just use an enum and get on with their lives. I’m not one of those people therefore I built myself something I call a TypeAlias which I use in many projects.

Imagine you have the assignment to store identifiers for different types of animals (Dolphin, Hippopotamus, and Tiger) and also have some code that behaves differently depending on the animal type. Of course, you do not simply want to use strings or magic numbers to do this.

First, let me demonstrate how this could be solved with an enum and tell you why this might not be the best solution.

The following code is the definition of the AnimalTypeEnum enum.

public enum AnimalTypeEnum
{
    Dolphin = 1,
    Hippopotamus = 2,
    Tiger = 3
}

So far so good. One requirement may be to store or transmit a textual representation of the AnimalTypeEnum instead of the numeric value of the enum member. This could be done by a piece of code similar to the following.

var tigerString = AnimalTypeEnum.Tiger.ToString();

Now for the inverse, the following can be used.

if (Enum.TryParse("Dolphin", out AnimalTypeEnum a))
{
    // it worked
}

Now for some fun stuff. What happens if you work with the numerical values of the enum and you want to convert this number back to the enum?

var animalType = (AnimalTypeEnum) 1;
Console.WriteLine(animalType.ToString());
// prints "Dolphin"

The example above would work but what would happen if there was once the enum member AnimalTypeEnum.Dodo with the value 4. Because of its extinction, it was removed from the code but some entries that are using the value still exist in the database.

var animalType = (AnimalTypeEnum) 4;
Console.WriteLine(animalType.ToString());
// prints "4"

This behavior certainly is not pretty. There is no warning of any kind. The correct way is to always check if the provided value is even defined before casting it. This is demonstrated in the next snippet.

var v = 4;
if (Enum.IsDefined(typeof(AnimalTypeEnum), v))
{
    var animalType = (AnimalTypeEnum) v;
    Console.WriteLine(animalType.ToString());
}
// no output

When using enums this validation should be made in every function that accepts enum values to protect the program from invalid enum values. A similar case of this happens when an uninitialized variable of the enum type is used. Per default, such a variable has the value 0. Therefore it is recommended to define an enum member with the value 0 that is defined as nothing or unknown. Otherwise, the following case is possible.

public class Thing
{
    public AnimalTypeEnum AnimalType { get; }
}

var thing = new Thing();
Console.WriteLine(thing.AnimalType.ToString());
// prints "0"

It may seem petty to not use enums because of the issues mentioned above but when I write software I like to make impossible states impossible and enums provide way too many ways in which they could be misused.

This is where the following construct comes in to save the day. The following snipped contains an abstract class that can be inherited from to build strongly typed aliases for types like strings or integers.

/// <summary>
/// A generic class that can be used
/// to create a strongly typed alias of a type.
/// </summary>
/// <typeparam name="T">
/// Type of the value.
/// </typeparam>
public abstract class TypeAlias<T>
    : IEquatable<TypeAlias<T>>,
      IEquatable<T> where T : notnull
{
    /// <summary>
    /// Value of the instance.
    /// </summary>
    public T Value { get; }

    protected TypeAlias(T value)
    {
        Value = value;
    }

    public static implicit operator T?(TypeAlias<T>? typeAlias)
    {
        return typeAlias is null ? default : typeAlias.Value;
    }

    /// <inheritdoc />
    public override string ToString()
    {
        return Value.ToString() ?? string.Empty;
    }


    /// <inheritdoc />
    public bool Equals(TypeAlias<T>? other)
    {
        if (ReferenceEquals(null, other))
        {
            return false;
        }

        if (ReferenceEquals(this, other))
        {
            return true;
        }

        return EqualityComparer<T>.Default.Equals(Value, other.Value);
    }

    /// <inheritdoc />
    public override bool Equals(object? obj)
    {
        if (ReferenceEquals(null, obj))
        {
            return false;
        }

        if (ReferenceEquals(this, obj))
        {
            return true;
        }

        if (obj.GetType() != this.GetType())
        {
            return false;
        }

        return Equals((TypeAlias<T>)obj);
    }

    /// <inheritdoc />
    public bool Equals(T? other)
    {
        return Value.Equals(other);
    }

    /// <inheritdoc />
    public override int GetHashCode() =>
     EqualityComparer<T>.Default.GetHashCode(Value);

    public static bool operator ==(TypeAlias<T>? objA, TypeAlias<T>? objB)
    {
        return Equals(objA, objB);
    }

    public static bool operator !=(TypeAlias<T>? objA, TypeAlias<T>? objB)
    {
        return !(objA == objB);
    }

    public static bool operator ==(TypeAlias<T>? objA, T? objB)
    {
        if (objA is null)
        {
            return objB == null;
        }

        return objA.Value.Equals(objB);
    }

    public static bool operator !=(TypeAlias<T>? objA, T? objB)
    {
        return !(objA == objB);
    }

    public static bool operator ==(T? objA, TypeAlias<T>? objB)
    {
        return objB == objA;
    }

    public static bool operator !=(T? objA, TypeAlias<T>? objB)
    {
        return !(objB == objA);
    }
}

The TypeAlias<T> has a Value property which stores the value of the instance. It implements IEquatable<TypeAlias<T>> in a way that uses its value to determine equality with other instances. Additionally IEquatable<T> is implemented so it can be compared with values of type T. This means that for example the equality between a TypeAlias<string> and a string can be determined. The corresponding operators == and != are also overloaded. Last but not least the implicit cast operator is implemented for T? which makes it possible to for example cast a TypeAlias<int> to an int.

So how would I implement the AnimalType enum from above as a TypeAlias and perform similar operations as above? The implementation of the TypeAlias is provided in the next snippet.

public class AnimalType : TypeAlias<string>
{
    private AnimalType(string value)
        : base(value) { }

    public static AnimalType Dolphin { get; } =
        new("Dolphin");
    public static AnimalType Hippopotamus { get; } =
        new("Hippopotamus");
    public static AnimalType Tiger { get; } =
        new("Tiger");

    public static IImmutableSet<AnimalType> PossibleValues { get; } =
        new HashSet<AnimalType>() { Dolphin, Hippopotamus, Tiger }
            .ToImmutableHashSet();

    public static AnimalType? FromString(string value)
    {
        StringToAnimalType.TryGetValue(value, out var animalType);
        return animalType;
    }

    private static readonly IImmutableDictionary<string, AnimalType> StringToAnimalType =
        PossibleValues.ToImmutableDictionary(e => e.Value, e => e);
}

Using the new AnimalType as string can be achieved by calling the ToString() method similar to the enum example above or by using the implicit conversion as demonstrated below.

string dolphin = AnimalType.Dolphin;

There is no need to validate if a value is defined or not when using a variable of type AnimalType because it is not possible to produce an instance of the AnimalType that contains an invalid value. To create an instance of AnimalType from a string the FromString method can be used that returns null when the value is invalid.

Not only does using the TypeAlias class enable the creation of safer code it also works nicely together with EntityFramework Core value conversions to convert between raw values and TypeAlias implementations. This means that all application code can use the safe TypeAlias and values of primitive types and validations of those values only have to be present at the edges of the application.