Search code examples
c#booleanstring-interpolation

String interpolation with boolean formatting


How can I specify a format string for a boolean that's consistent with the other format strings for other types?

Given the following code:

double d = Math.PI;
DateTime now = DateTime.Now;
bool isPartyTime = true;

string result = $"{d:0.0}, {now:HH:mm}, time to party? {isPartyTime}";

I can specify a format for every primitive type, except for bool it seems. I know I can do:

string result = $"{d:0.0}, {now:HH:mm}, time to party? {(isPartyTime ? "yes!" : "no")}";

However this is still inconsistent with the other types.

Is there a way of formatting booleans in interpolated strings that is consistent?

P.S. I did search for an answer including this link:

https://stackoverflow.com/questions/tagged/c%23+string-interpolation+boolean

And surprisingly had zero results.


Solution

  • Using C# 10.0? Just use a String Interpolation Handler

    Custom String Interpolation Handlers are documented here and here

    (I don't have any experience with any C# 10.0 features yet, but I'll expand this section in future - right now I'm still stuck in C# 7.3 land due to my day-job's projects' dependencies on .NET Framework 4.8)

    Using C# 1.0 through C# 9.0?

    Quick-fix: Boolean wrapper struct

    If you control the string-formatting call-sites, then just change bool/Boolean-typed values to use an implicitly-convertible zero-overhead value-type instead, e.g.:

    public readonly struct YesNoBoolean : IEquatable<YesNoBoolean>
    {
        // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/user-defined-conversion-operators
        public static implicit operator Boolean  ( YesNoBoolean self ) => self.Value;
        public static implicit operator YesNoBoolean( Boolean value ) => new MyBoolean( value );
    
        // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/true-false-operators
        public static Boolean operator true( YesNoBoolean self ) => self.Value == true;
        public static Boolean operator false( YesNoBoolean self ) => self.Value == false;
    
        public YesNoBoolean( Boolean value )
        {
            this.Value = value;
        }
    
        public readonly Boolean Value;
    
        public override String ToString()
        {
            return this.Value ? "Yes" : "No";
        }
    
        // TODO: Override Equals, GetHashCode, IEquatable<YesNoBoolean>.Equals, etc.
    }
    

    So your example call-site becomes:

    double d = Math.PI;
    DateTime now = DateTime.Now;
    YesNoBoolean isPartyTime = true;  // <-- Yay for implicit conversion.
    
    string result = $"{d:0.0}, {now:HH:mm}, time to party? {isPartyTime}";
    

    And result will be "3.1, 21:03, time to party? Yes"

    Bubble-bursting: No, you can't overwrite Boolean.TrueString and FalseString

    Because Boolean's static readonly String TrueString = "True"; is also marked with initonly you cannot overwrite it using reflection, so doing this:

    typeof(Boolean).GetField( "TrueString" )!.SetValue( obj: null, value: "Yes" );
    

    ...will give you a runtime exception:

    Cannot set initonly static field 'TrueString' after type 'System.Boolean' is initialized.

    It is still possible by manipulating raw memory, but that's out-of-scope for this question.

    Using IFormatProvider and ICustomFormatter:

    It's always been possible to override how both String.Format and interpolated strings (e.g. $"Hello, {world}") are formatted by providing a custom IFormatProvider; though while String.Format makes it easy by exposing a Format overload parameter, interpolated strings do not, instead it forces you to uglify your code somewhat.

    • Implementing IFormatProvider is (still) surprisingly underdocumented in .NET.
      • The main thing to remember is that IFormatProvider.GetFormat(Type) is only ever invoked with one of these 3 formatType arguments:
        • typeof(DateTimeFormatInfo)
        • typeof(NumberFormatInfo)
        • typeof(ICustomFormatter)
      • Throughout the entire .NET BCL, no other typeof() types are passed into GetFormat (at least as far as ILSpy and RedGate Reflector tell me).

    The magic happens inside ICustomFormatter.Format and implementing it is straightforward:

    public class MyCustomFormatProvider : IFormatProvider
    {
        public static readonly MyCustomFormatProvider Instance = new MyCustomFormatProvider();
    
        public Object? GetFormat( Type? formatType )
        {
            if( formatType == typeof(ICustomFormatter) )
            {
                return MyCustomFormatter.Instance;
            }
            
            return null;
        }
    }
    
    public class MyCustomFormatter : ICustomFormatter
    {
        public static readonly MyCustomFormatter Instance = new MyCustomFormatter();
    
        public String? Format( String? format, Object? arg, IFormatProvider? formatProvider )
        {
            // * `format` is the "aaa" in "{0:aaa}"
            // * `arg` is the single value 
            // * `formatProvider` will always be the parent instance of `MyCustomFormatProvider` and can be ignored.
    
            if( arg is Boolean b )
            {
                return b ? "Yes" : "No";
            }
    
            return null; // Returning null will cause .NET's composite-string-formatting code to fall-back to test `(arg as IFormattable)?.ToString(format)` and if that fails, then just `arg.ToString()`.
        }
    
        public static MyFormat( this String format, params Object?[] args )
        {
            return String.Format( Instance, format: format, arg: args );
        }
    }
    

    ...so just pass MyCustomFormatProvider.Instance into String.Format somehow, like below.

    double d = Math.PI;
    DateTime now = DateTime.Now;
    bool isPartyTime = true;
    
    string result1 = String.Format( MyCustomFormatProvider.Instance, "{0:0.0}, {1:HH:mm}, time to party? {2}", d, now, isPartyTime );
    
    // or add `using static MyCustomFormatProvider` and use `MyFormat` directly:
    string result2 = MyFormat( "{0:0.0}, {1:HH:mm}, time to party? {2}", d, now, isPartyTime );
    
    // or as an extension method:
    string result3 = "{0:0.0} {1:HH:mm}, time to party? {2}".MyFormat( d, now, isPartyTime );
    
    // Assert( result1 == result2 == result3 );
    

    So that works for String.Format, but how can we use MyCustomFormatProvider with C# $"" interpolated strings...?

    ...with great difficulty, because the C# langauge team who designed the interpolated strings feature made it always pass provider: null so all values use their default (usually Culture-specific) formatting, and they didn't provide any way to easily specify a custom IFormatProvider, even though there's decades-old Static Code Analysis rule against relying on implicit use of CurrentCulture (though it's not uncommon for Microsoft to break their own rules...).

    • Unfortunately overwriting CultureInfo.CurrentCulture won't work because Boolean.ToString() doesn't use CultureInfo at all.

    The difficulty stems from the fact that C# $"" interpolated strings are always implicitly converted to String (i.e. they're formatted immediately) unless the $"" string expression is directly assigned to a variable or parameter typed as FormattableString or IFormattable, but infuriatingly this does not extend to extension methods (so public static String MyFormat( this FormattableString fs, ... ) won't work.

    The the only thing that can be done here is to invoke that String MyFormat( this FormattableString fs, ... ) method as a (syntactically "normal") static method call, though using using static MyFormattableStringExtensions somewhat reduces the ergonomics problems - even more-so if you use global-usings (which requires C# 10.0, which already supports custom interpolated-string handlers, so that's kinda moot).

    But like this:

    public static class MyFormattableStringExtensions
    {
        // The `this` modifier is unnecessary, but I'm retaining it just-in-case it's eventually supported.
        public static String MyFmt( this FormattableString fs )
        {
            return fs.ToString( MyCustomFormatProvider.Instance );
        }
    }
    

    And used like this:

    using static MyFormattableStringExtensions;
    
    // ...
    
    double d = Math.PI;
    DateTime now = DateTime.Now;
    bool isPartyTime = true;
    
    string result = MyFmt( $"{d:0.0}, {now:HH:mm}, time to party? {isPartyTime}" );
    Assert.AreEqual( result, "3.1, 23:05, time to party? Yes" );
    

    Or just mutate FormattableString's arguments array

    • Seeming as there's no alternative to wrapping an interpolated string in a function call (like MyFmt( $"" ) above), there's a simpler alternative approach to having to implement IFormatProvider and ICustomFormatter: just edit the FormattableString's value arguments array directly.
    • Because this approach is significantly simpler it is preferable if you don't also need to format Boolean values in String.Format(IFormatProvider, String format, ...).
    • Like so:
    public static class MyFormattableStringExtensions
    {
        public static String MyFmt( this FormattableString fs )
        {
            if( fs.ArgumentCount == 0 ) return fs.Format;
            Object?[] args = fs.GetArguments();
            for( Int32 i = 0; i < args.Length; i++ )
            {
                if( args[i] is Boolean b )
                {
                    args[i] = b ? "Yes" : "No";
                }
            }
            return String.Format( CultureInfo.CurrentCulture, fs.Format, arg: args  );
        }
    }
    

    And used just like before to get the same results:

    using static MyFormattableStringExtensions;
    
    // ...
    
    double d = Math.PI;
    DateTime now = DateTime.Now;
    bool isPartyTime = true;
    
    string result = MyFmt( $"{d:0.0}, {now:HH:mm}, time to party? {isPartyTime}" );
    Assert.AreEqual( result, "3.1, 23:05, time to party? Yes" );