String literal types in C#

Pier-Luc Bonneville
Pier-Luc Bonneville
Being technical is important at the leadership level in a world where IT is eating the world.
Oct 21, 2020 5 min read
thumbnail for this post
Photo by Pexels

Introduction

In this post we’ll explore how we can guarantee the value of strings at compile time.

Contrary to Typescript, C# doesn’t have string literal types.

Consider this example where the parameters of the write method are of type string and string. The first parameter can only be of type string with the value of "log" or "info". Any other string value will be marked as an error by the TypeScript compiler.

export function hello(): void {
    write("log", "Hello world!");  // OK
    write("info", "Hello world!"); // OK

    // (TS 2345)
    // Argument of type '"warning"' is not assignable to parameter of type:
    // '"log" | "info"'
    write("warning", "Hello world!");
}

export function write(level: "log" | "info", message: string): void {
    if (level === "log") {
        console.log(message);
    }

    if (level === "info") {
        console.info(message);
    }

    // (TS 2367)
    // This condition will always return 'false' since the types:
    // '"log" | "info"' and '"warning"' have no overlap
    if (level === "warning") {
        console.warn(message);
    }
}

In C# we have no easy way (à la TypeScript) for the compiler to enforce this level of type safety using strings.

The solution

To achieve this in C#, we first need to create a class that encapsulates the log level string.

public class LogLevel : IEquatable<LogLevel>
{
    private readonly string _value;

    private LogLevel(string value) =>_value = value;

    public static LogLevel Log => new LogLevel("Log");

    public static LogLevel Info => new LogLevel("Info");

    public bool Equals(LogLevel other)
    {
        if (other is null)
        {
            return false;
        }

        return _value == other._value;
    }

    public override bool Equals(object obj) => Equals(obj as LogLevel);

    public override int GetHashCode() => _value.GetHashCode();

    public static bool operator ==(LogLevel a, LogLevel b)
    {
        if (a is null || b is null)
        {
            return Equals(a, b);
        }

        return a.Equals(b);
    }

    public static bool operator !=(LogLevel a, LogLevel b) => !(a == b);
}

Update (2020-10-21): Thanks to FizixMan we can skip creating all the extra instances and string comparisons code if we convert our properties to fields; greatly simplify the LogLevel class to:

public class LogLevel
{
    private readonly string _value;

    private LogLevel(string value) => _value = value;

    public override string ToString() => _value;

    public static readonly LogLevel Log = new LogLevel("Log");

    public static readonly LogLevel Info = new LogLevel("Info");
}

Our Write method can now accept strongly type log levels where we are assured that the string will be of the desired value.

private static void Write(LogLevel level, string message)
{
    _ = level ?? throw new ArgumentNullException(nameof(level));

    if (level == LogLevel.Log)
    {
        Console.WriteLine($"Logging...{message}");
    }
    else if (level == LogLevel.Info)
    {
        Console.WriteLine($"Information...{message}");
    }
}

We could get rid of the guard clause verifying that the level parameter isn’t null if using the C# 8.0 Nullable reference types.

The switch statement

One issue with our approach is that this doesn’t work out-of-the-box with the switch statement. The compiler is expecting constant values when using the switch statement.

using System;
using System.Collections.Generic;
using System.Linq;

public class Program
{
    public static void Main()
    {
        Write(LogLevel.Log, "Hello world");
    }

    private static void Write(LogLevel level, string message)
    {
        switch (level)
        {
            case LogLevel.Log: // error CS0150: A constant value is expected
                Console.WriteLine($"Logging...{message}");
                break;
            case LogLevel.Info: // error CS0150: A constant value is expected
                Console.WriteLine($"Information...{message}");
                break;
            case null:
                Console.WriteLine("The value is null");
                break;
            default:
                Console.WriteLine("The value is invalid");
                break;
        }
    }
}

We can fix this compiler error by changing the condition for a type pattern and then checking the value of the type with the when clause.

using System;
using System.Collections.Generic;
using System.Linq;

public class Program
{
    public static void Main()
    {
        Write(LogLevel.Log, "Hello world");
    }

    private static void Write(LogLevel level, string message)
    {
        switch (level)
        {
            case { } x when x == LogLevel.Log:
                Console.WriteLine($"Logging...{message}");
                break;
            case { } x when x == LogLevel.Info:
                Console.WriteLine($"Information...{message}");
                break;
            case null:
                throw new ArgumentNullException(nameof(level));
            default:
                throw new ArgumentException("The value is invalid.", nameof(level));
        }
    }
}

//Output:
//Logging...Hello world

The switch expression

We can also do the same for the switch expression (you’ll need the when clause again):

using System;
using System.Collections.Generic;
using System.Linq;

public class Program
{
    public static void Main()
    {
        Write(LogLevel.Log, "Hello world");
    }

    private static void Write(LogLevel level, string message)
    {
        Action next = level switch
        {
            { } x when x == LogLevel.Log  => () => Console.WriteLine($"Logging...{message}");,
            { } x when x == LogLevel.Info => () => Console.WriteLine($"Information...{message}"),
            null => throw new ArgumentNullException(nameof(level)),
            _    => throw new ArgumentException("The value is invalid", nameof(level))
        };

        next();
    }
}

//Output:
//Logging...Hello world

Code improvements

  1. We could provide helper methods such as overriding the implicit operator for strings but we would loose the strong typing by doing so.

    public static implicit operator LogLevel(string level)
    
  2. We could also replace the constant strings with the nameof operator:

    public static LogLevel Log => new LogLevel(nameof(Log));
    
  3. The TypeScript example could have been written with union types:

    type LogLevel = "log" | "info";
    

Conclusion

This works but it’s not pretty. Yes, we get type safety but with added complexity.

On a side note, serialization will be an issue with this solution. You could be better off with enums, but these also have their own idiosyncrasies that we’ll explore in a future post.

References

  1. TypeScript: Handbook - Literal Types
  2. switch (C# reference) - The case statement and the when clause
  3. switch expression (C# reference) - Patterns and case guards