7

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.

Mark Rotteveel
  • 100,966
  • 191
  • 140
  • 197
Ian Newson
  • 7,679
  • 2
  • 47
  • 80
  • Sort-of: it’s doable if you mutate `FormattableString`’s Arguments array in a helper method. It sucks that you can’t use extension-methods with `FormattableString` either. – Dai Feb 23 '22 at 23:39
  • @Dai thanks, I know there are various methods of doing this but they all seem to require some kind of workaround. I think the key part of my question is consistency. – Ian Newson Feb 23 '22 at 23:45
  • I think the reason for the 'inconsistency' is that the 'string' representation of a boolean value in C# is in fact a keyword. The actual value (if I'm not mistaken) is either 0 for 'false' or non-zero for 'true'. It isn't that the `FormattableString' isn't consistent but rather the specification's actual representation of type value isn't consistent. What other type has keywords for values? I can't think of any. – dmedine Feb 23 '22 at 23:47
  • 1
    @dmedine well, `null` is another. – Dai Feb 24 '22 at 00:16
  • @Dai, null isn't a type. – dmedine Feb 24 '22 at 00:47
  • @dmedine no, but `System.Void` is and colloquially they're the same, I think – Ian Newson Feb 24 '22 at 00:52
  • I'm not so sure you can call `void` a 'type' either. It certainly isn't a type you can assign to a variable to. Just try to compile `void x = 123;`. `void` is emptiness. If you do something like `string? nothing = null; Console.WriteLine($"nothing:{nothing}" la la la);` it prints `nothing: la la la`. – dmedine Feb 24 '22 at 00:59
  • Also, `void` is not the same thing as `null`. `null` is a reference to no object (https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/null) while `void` is a return type (so it is a kind of type...) that specifies a method that does not return a value (so it also isn't a type...) https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/void – dmedine Feb 24 '22 at 01:02
  • @dmedine There is a philosophical discussion to be had, but in .NET it is literally a type: https://learn.microsoft.com/en-us/dotnet/api/system.void?view=net-6.0 . Probably in many others too, like python: `type(None) => ' – Ian Newson Feb 24 '22 at 01:03
  • 1
    @dmedine I meant that in .NET’s type-system, “`null`” is a keyword that represents a specific value for an object-reference (I’m firmly outside the “null is the absence of a value” school-of-thought. Those people are monsters: they gave us SQL!) – Dai Feb 24 '22 at 01:05
  • @Dai, this is getting way to discussion-y, but I was just trying to make the point that there (probably) aren't types other than `bool` where keywords are the de facto values you assign to the variables when you write the source code, and then how do you formulate a formatted string given that? (For the answer(s) to that question read below...) And for the record, I fully agree that null is a thing (that is nothing) and SQL is the only thing worse than html – dmedine Feb 24 '22 at 01:12
  • @Dai `NULL` in SQL doesn't mean the absence of a value, it means an unknown value. This is not the thing. That *could* be because the value is absent (as in it's not applicable), but could also mean that it truly isn't known (perhaps it wasn't asked). – Thom A Feb 24 '22 at 12:57
  • @JeremyThompson hi, you closed this question and marked it as a duplicate but it is not as the proposed question has nothing to do with string interpolation (I don't think that feature was even in C# when the proposed question was asked). Could you explain why you did this please? – Ian Newson Feb 24 '22 at 13:05
  • Hi @IanNewson no worries I'll reopen. I was hanging around yesterday when this Null discussion... – Jeremy Thompson Feb 24 '22 at 22:01
  • @IanNewson I explicitly closed it as the 2nd answer is EXACTLY what you're trying to do, and all the solutions here are already there!!! I'm tempted to close again, why isn't it what you want? – Jeremy Thompson Feb 24 '22 at 22:35
  • @JeremyThompson the key part of my question is consistency with the other string interpolation mechanisms. I'm aware that there are mechanisms to functionally achieve the same thing (as I indicated in the question), but was asking about consistency. An extension method is not consistent with the other built in format specifiers. I already suspected the answer was 'no', but even that's a good thing to have documented imo. – Ian Newson Feb 24 '22 at 23:07
  • @IanNewson make the suggestion to the .Net team, currently they're looking for ideas. – Jeremy Thompson Feb 24 '22 at 23:11
  • @Larnu You are correct. But my point was more concerned with how SQL makes `NULL` "viral" and effectively disallows all meaningful operations involving `NULL`, which I think is a major design mistake (amongst many others...), if C# was like SQL then the expression `String.Format( "hello {0} and {1}", null, "Bob" )` would return `null` (instead of "Hello and Bob") just because the argument for `{0}` was `null` - as is the case with SQL's `CONCAT` today. – Dai Feb 25 '22 at 04:55
  • @Dai because `CONCAT` is **explicitly** documented to behave that way. `Hello ' + NULL + ' and Bob'` returns `NULL`; just as you expect. Your analogy is flawed. – Thom A Feb 25 '22 at 08:02
  • @Larnu I know, I'm saying the _design_ of `CONCAT` is a bad design. Just because something is thoroughly documented, and even an ISO standard, doesn't mean it's not a bad design :) – Dai Feb 25 '22 at 08:07
  • Which has nothing to do with `NULL` and that you said that SQL treats it like the absence of a value @Dai . How `CONCAT` *treats* `NULL` and how `NULL` operates are completely different things. `CONCAT` is most certainly the outlier for that treatment too. I'm sure I could find a function in .Net that doesn't behave like everything else if I took the time. – Thom A Feb 25 '22 at 08:53
  • @Larnu As much as I'd love to continue this conversation (honestly! - I know this sounds sarcastic), but this comment thread has really derailed. Can we do a chat about it privately/separately? – Dai Feb 25 '22 at 09:10

3 Answers3

10

Unfortunately, no, there isn't.

According to Microsoft, the only data types with format strings are:

  • Date and time types (DateTime, DateTimeOffset)
  • Enumeration types (all types derived from System.Enum)
  • Numeric types (BigInteger, Byte, Decimal, Double, Int16, Int32, Int64, SByte, Single, UInt16, UInt32, UInt64)
  • Guid
  • TimeSpan

Boolean.ToString() can only return "True" or "False". It even says, if you need to write it to XML, you need to manually perform ToLowerCase() (from the lack of string formatting).

NPras
  • 3,135
  • 15
  • 29
  • Thanks; obvs I'm going keep this question open for the time being in case Anders himself appears, but I think you're correct. – Ian Newson Feb 23 '22 at 23:51
  • I'd be surprised if it did. The other datatypes mentioned above have a [ToString(string)](https://learn.microsoft.com/en-us/dotnet/api/system.int16.tostring) overload, which Boolean lacks. – NPras Feb 23 '22 at 23:56
  • @NPras It’s `IFormattable` that you want to look out for, not just `public` methods (as many instances of explicit interface implementations are undocumented, but still functional) – Dai Feb 24 '22 at 01:09
  • So you know another answer was posted which contains a solution for C# 10, so I've decided that that is the correct answer and switched the 'tick' – Ian Newson Feb 26 '22 at 14:02
  • 1
    @IanNewson and what an answer that was! Way more deserving. – NPras Feb 27 '22 at 23:21
3

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" );
Dai
  • 141,631
  • 28
  • 261
  • 374
  • 1
    Thanks, I've written an implementation of InterpolatedStringHandler here: https://github.com/IanPNewson/NugetPackages/blob/master/INHelpers/InterpolatedStrings/BoolFormatInterpolatedStringHandler.cs and published it in this package: https://nuget.org/packages/INHelpers – Ian Newson Feb 26 '22 at 16:59
1

This may be obvious but to cut down the repetition, you could always create an extension method. It gets you half way there at least.

public static class MyExtensions
{
    public static string ToYesNo(this bool boolValue)
    {
        return boolValue ? "Yes" : "No";
    }
}

static void Main(string[] args)
{
    var booleanValue = true;

    Console.WriteLine(booleanValue.ToYesNo());
}
Skin
  • 9,085
  • 2
  • 13
  • 29