Table of Contents

Formatting and parsing Fraction<T>

Exact rational values only round-trip when their textual form preserves every bit of the canonical representation. Fraction<T> ships three first-class text forms - the improper ratio 7/3, the mixed number 2 1/3, and the single-codepoint Unicode glyph 2⅓ - plus a percentage form. Every output form is also an accepted input form, so any ToString result feeds back through Parse to the same value.

This guide covers what each specifier renders, what the parser accepts, and how culture and span surfaces interact with both. For the rest of the type, start with Working with Fraction<T>.

Format specifiers at a glance

Specifier Output Example (7/3)
G (default) improper ratio 7/3
M mixed number 2 1/3
U Unicode vulgar fraction with mixed-number fallback 2⅓
P percentage (ratio form) 700/3%

Specifiers are case-insensitive - Format uppercases the first character - and the format string null, "", or "G" all select the general form. Any other specifier throws FormatException.

Improper-ratio form (default)

ToString() and ToString("G") render the canonical pair as numerator/denominator. When the canonical denominator is one, the slash and denominator are omitted so whole numbers print as a bare integer:

Fraction<int>.Create(3, 4).ToString();    // "3/4"
Fraction<int>.Create(-7, 4).ToString();   // "-7/4"  - sign rides on the numerator
Fraction<int>.Create(6, 2).ToString();    // "3"     - canonical denominator is one
Fraction<int>.Zero.ToString();            // "0"

The general form is the round-trip wire shape: JsonSerializer.Serialize (see below) and <xref:Bodu.Numerics.Fraction1>.Parseboth treat"n/d"` as the lossless text form.

Mixed-number form

ToString("M") separates the whole part from the proper remainder with a single space. The whole part carries the sign; the fractional part is always written with a non-negative numerator. Whole-number and proper-fraction values short-circuit to their bare forms:

Fraction<int>.Create(7, 4).ToString("M");    // "1 3/4"
Fraction<int>.Create(-7, 4).ToString("M");   // "-1 3/4"  - sign on the whole part
Fraction<int>.Create(11, 4).ToString("M");   // "2 3/4"
Fraction<int>.Create(3, 4).ToString("M");    // "3/4"     - proper fraction: no whole part
Fraction<int>.Create(4, 1).ToString("M");    // "4"       - whole number: no fractional part
Fraction<int>.Zero.ToString("M");            // "0"

The convenience methods Fraction<T>.ToMixedString(provider) and ToMixedNumberString(provider) are aliases for ToString("M", provider).

Unicode vulgar-fraction form

ToString("U") emits a single-codepoint glyph when one exists for the proper-fraction part. The 18 glyphs supported are the Unicode "Number Forms" vulgar fractions:

Glyph Value Glyph Value Glyph Value
½ 1/2 ⅖ 2/5 ⅛ 1/8
⅓ 1/3 ⅗ 3/5 ⅜ 3/8
⅔ 2/3 ⅘ 4/5 ⅝ 5/8
¼ 1/4 ⅙ 1/6 ⅞ 7/8
¾ 3/4 ⅚ 5/6 ⅑ 1/9
⅕ 1/5 ⅐ 1/7 ⅒ 1/10

When the canonical proper-fraction remainder matches one of these pairs, the result is [sign][whole part][glyph] with the whole part suppressed when zero. When no glyph applies, the formatter falls back to the mixed-number form:

Fraction<int>.Create(1, 2).ToString("U");    // "½"
Fraction<int>.Create(3, 4).ToString("U");    // "¾"
Fraction<int>.Create(7, 4).ToString("U");    // "1¾"     - whole part + glyph, no separator
Fraction<int>.Create(-3, 4).ToString("U");   // "-¾"
Fraction<int>.Create(5, 2).ToString("U");    // "2½"
Fraction<int>.Create(3, 1).ToString("U");    // "3"      - whole number
Fraction<int>.Create(5, 9).ToString("U");    // "5/9"    - no 5/9 glyph: falls back to mixed

The convenience method Fraction<T>.ToUnicodeString(provider) is an alias for ToString("U", provider).

Note

A glyph is emitted only when the proper-fraction remainder has denominator at most 16 and matches one of the 18 shipped pairs. 5/9 has no glyph and falls back to mixed form even though its denominator is under 16; the table - not the denominator alone - is the gate.

Percentage form

ToString("P") scales the value by 100, reduces the result to lowest terms, and renders it as a ratio with a trailing %. It is not a mixed number: a non-whole percentage prints as numerator/denominator%, and a whole one prints as numerator%:

Fraction<int>.Create(3, 4).ToString("P");    // "75%"     - 300/4 reduces to 75/1
Fraction<int>.Create(7, 4).ToString("P");    // "175%"    - 700/4 reduces to 175/1
Fraction<int>.Create(7, 3).ToString("P");    // "700/3%"  - 700/3 already in lowest terms
Fraction<int>.Create(1, 3).ToString("P");    // "100/3%"
Fraction<int>.Zero.ToString("P");            // "0%"

The convenience method Fraction<T>.ToPercentString(provider) is an alias for ToString("P", provider). The parser accepts the same form on input - a trailing % divides the parsed denominator by 100 - so "75%" round-trips to 3/4 and "100/3%" to 1/3.

Parsing

Fraction<T>.Parse and TryParse accept every output form the type produces, plus a few additional ergonomic shapes. The grammar, in order of recognition:

Input shape Example Parses as
Whole integer "3", "-5", "+12" 3/1, -5/1, 12/1
Ratio "3/4", "-7/2" 3/4, -7/2
Mixed number (space or tab separated) "2 1/3", "-1 3/4" 7/3, -7/4
Vulgar-fraction glyph "½", "⅗" 1/2, 3/5
Whole + glyph "2⅜", "-1¾" 19/8, -7/4
Percentage "75%", "100/3%" 3/4, 1/3

Parsing is lenient about whitespace - leading and trailing whitespace is trimmed, and the whole / fractional parts of a mixed number are trimmed individually. A + or - sign at the start applies to the entire result; for mixed numbers the sign therefore rides on the whole + fraction sum, not the whole part alone. The trailing % divides the parsed denominator by 100 in lowest terms.

Fraction<int>.Parse("3/4");          // 3/4
Fraction<int>.Parse("  2 1/3  ");    // 7/3       - whitespace trimmed
Fraction<int>.Parse("-2 1/3");       // -7/3      - sign covers whole + fraction
Fraction<int>.Parse("⅗");            // 3/5
Fraction<int>.Parse("2⅜");           // 19/8
Fraction<int>.Parse("75%");          // 3/4
Fraction<int>.TryParse("nope", out var _);   // false

The numeric components are read with NumberStyles.None, so scientific notation and group separators are rejected; the parser is intentionally strict about the shape so the wire format remains unambiguous. A 0 denominator is rejected (TryParse returns false; Parse throws FormatException), as is any input whose canonical form does not fit in the backing type T - overflow is reported through false from TryParse and through FormatException from Parse.

Fraction<T> implements IParsable<TSelf> and ISpanParsable<TSelf>, so the same call sites work for string and ReadOnlySpan<char> inputs.

Culture handling

Fraction<T> text is digit-slash-digit by construction, so most cultures behave identically - there is no decimal separator, group separator, or percent sign placement to vary. The IFormatProvider argument is forwarded to BigInteger.ToString(provider) and BigInteger.TryParse(text, NumberStyles.None, provider, ...) for the numeric components, so culture-specific digit shapes are respected (e.g. Arabic-Indic digits when the culture's NumberFormatInfo calls for them), but the structural characters - /, the mixed-number space, glyph codepoints, and the trailing % - are invariant.

The JSON converter passes CultureInfo.InvariantCulture to both ToString and Parse so the wire form remains stable regardless of the ambient culture. For application-level formatting where you want the structural form to be the same on every machine, prefer passing CultureInfo.InvariantCulture explicitly:

var text = value.ToString("M", CultureInfo.InvariantCulture);
var back = Fraction<int>.Parse(text, CultureInfo.InvariantCulture);

Parse(string) without a provider is equivalent to Parse(string, null), which delegates to BigInteger.TryParse with a null provider - that uses the current culture, matching the BCL convention.

Span and UTF-8 surfaces

Fraction<T> implements the span and UTF-8 formatting / parsing interfaces so it slots into low-allocation pipelines:

  • ISpanFormattable.TryFormat(Span<char>, out int, ReadOnlySpan<char>, IFormatProvider?) - writes the formatted text into a char buffer; returns false when the destination is too small.
  • IUtf8SpanFormattable.TryFormat(Span<byte>, out int, ReadOnlySpan<char>, IFormatProvider?) - writes the same text UTF-8 encoded into a byte buffer.
  • ISpanParsable<TSelf>.TryParse(ReadOnlySpan<char>, IFormatProvider?, out Fraction<T>) - parses without allocating a string.
  • IUtf8SpanParsable<TSelf>.Parse(ReadOnlySpan<byte>, IFormatProvider?) and TryParse(...) - accept UTF-8 input.
Span<char> buffer = stackalloc char[16];
if (value.TryFormat(buffer, out int written, "M", CultureInfo.InvariantCulture))
{
    ReadOnlySpan<char> text = buffer[..written];
    // text is "1 3/4" for value 7/4
}

Fraction<int> parsed = Fraction<int>.Parse("3/4"u8, null);

Round-tripping JSON

JSON support ships in the companion Bodu.Numerics.Serialization.Json package (the core library is serialization-agnostic). Register the converters with AddNumericsJsonConverters; the default Strict policy emits the canonical object form:

using System.Text.Json;
using Bodu.Numerics.Serialization.Json;

var options = new JsonSerializerOptions().AddNumericsJsonConverters();

string json = JsonSerializer.Serialize(new Fraction<int>(3, 4), options);
// {"numerator":3,"denominator":4}

Fraction<int> roundTrip = JsonSerializer.Deserialize<Fraction<int>>(json, options);

The compact single-string form documented above ("3/4") is the wire shape of the Compact policy, opt-in via AddNumericsJsonConverters(NumericsJsonPolicy.Compact); its read path delegates to Fraction<T>.TryParse(text, CultureInfo.InvariantCulture, …), and the percentage and mixed-number text forms feed back through that same parser. See JSON serialization for the policy table and failure modes, and the Working with Fraction<T> guide for the equivalent XML helpers ToXml() / FromXml(string).

See also