String literal types in C#
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
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)
We could also replace the constant strings with the
nameof
operator:public static LogLevel Log => new LogLevel(nameof(Log));
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.