Skip to main content

Numbers

These methods turn numbers into text that people read: "21st", "one hundred twenty-three", "1.5 KB", "1.3M", "3/4". They also cover small calculations you write often, such as rounding to a step, percentages, linear rescaling, clamping and range checks. Use them on invoices, dashboards, file lists, schedules and anywhere else a number is shown to a user.

All methods live in the System namespace, so you do not need a using directive. Text output always uses the invariant culture.

At a glance​

MethodWhat it does
ToOrdinalWrites an English ordinal with digits: "1st", "22nd", "113th".
ToOrdinalWordsWrites an English ordinal in words: "first", "twenty-first", "one hundredth".
ToWordsWrites a number in English words: "one hundred twenty-three".
ToRomanWrites a number from 1 to 3999 as a Roman numeral: "MCMXCIV".
ToFileSizeFormats a byte count with 1024-based units: "1.5 KB", "3.3 MB".
ToCompactStringShortens a large count: "1.5K", "1.3M", "3B".
RoundToRounds to the nearest multiple of a step, halves away from zero.
ToFractionWrites a number as the closest mixed fraction: "1 3/4".
IsEven / IsOddChecks the parity of an int or long.
PercentOfReturns which percentage of a total a value is.
Digits / DigitSumReturns the decimal digits of a number, or their sum.
RescaleMaps a value linearly from one range to another.
IsBetweenChecks that a value lies between two bounds, both inclusive.
ClampLimits a value to a minimum and a maximum.
OverlapsChecks whether two half-open (Start, End) ranges share any part.
IncludesChecks whether a value falls in a half-open (Start, End) range.

Supported numeric types​

C# does not widen the receiver of an extension method. An extension on long cannot be called on an int, so the library adds explicit overloads where both make sense. The table shows which receiver types each method accepts.

Methodintlongdecimaldouble
ToOrdinal()yesnonono
ToOrdinalWords()yesnonono
ToWords()yesyesnono
ToRoman()yesnonono
ToFileSize(), ToFileSize(int decimals)yesyesnono
ToCompactString()yesyesnono
RoundTo(step)yesyesyesyes
ToFraction(), ToFraction(int maxDenominator)nonoyesyes
IsEven(), IsOdd()yesyesnono
PercentOf(total)yes (returns double)noyesyes
Digits(), DigitSum()yesyesnono
Rescale(...)nononoyes

IsBetween, Clamp, Overlaps and Includes are generic. They work on any type that implements IComparable<T>: numbers, DateTime, TimeSpan, string, Version and your own types.

When a receiver type is missing, cast first. For example, write ((double)stars).Rescale(1, 5, 0, 100) for an int.

Numbers as words and ordinals​

ToOrdinal​

ToOrdinal() appends the English ordinal suffix to an int. It handles the teens correctly: 11, 12 and 13 end in "th", while 21, 22 and 23 end in "st", "nd" and "rd".

Finishing positions
int position = 21;
string message = $"You finished {position.ToOrdinal()}"; // → "You finished 21st"

1.ToOrdinal(); // → "1st"
2.ToOrdinal(); // → "2nd"
3.ToOrdinal(); // → "3rd"
4.ToOrdinal(); // → "4th"
11.ToOrdinal(); // → "11th"
12.ToOrdinal(); // → "12th"
13.ToOrdinal(); // → "13th"
22.ToOrdinal(); // → "22nd"
101.ToOrdinal(); // → "101st"
111.ToOrdinal(); // → "111th"
0.ToOrdinal(); // → "0th"

Negative numbers keep their sign: (-1).ToOrdinal() returns "-1st". The digits are written with the invariant culture, so there are no group separators: int.MinValue.ToOrdinal() returns "-2147483648th".

ToOrdinalWords​

ToOrdinalWords() writes the ordinal in words. Only the last word changes, so 21 becomes "twenty-first" and 101 becomes "one hundred first". Use it for formal text such as "the twenty-first annual meeting".

Ordinals in words
1.ToOrdinalWords(); // → "first"
2.ToOrdinalWords(); // → "second"
3.ToOrdinalWords(); // → "third"
12.ToOrdinalWords(); // → "twelfth"
20.ToOrdinalWords(); // → "twentieth"
21.ToOrdinalWords(); // → "twenty-first"
42.ToOrdinalWords(); // → "forty-second"
100.ToOrdinalWords(); // → "one hundredth"
101.ToOrdinalWords(); // → "one hundred first"
2500.ToOrdinalWords(); // → "two thousand five hundredth"
1_000_000.ToOrdinalWords(); // → "one millionth"
0.ToOrdinalWords(); // → "zeroth"
warning

A negative number throws ArgumentOutOfRangeException with the parameter name number. ToOrdinalWords() has only an int overload.

Compare the three ways to write 21: 21.ToOrdinal() is "21st", 21.ToOrdinalWords() is "twenty-first" and 21.ToWords() is "twenty-one".

ToWords​

ToWords() writes an int or long in English words. It goes up to the quintillions, so every long value works, including long.MinValue. It uses American style: no "and" after "hundred", and a hyphen between tens and units. Use it for cheques, invoice totals and accessibility text.

Numbers in words
0.ToWords(); // → "zero"
21.ToWords(); // → "twenty-one"
123.ToWords(); // → "one hundred twenty-three"
1001.ToWords(); // → "one thousand one"
1_000_001.ToWords(); // → "one million one"
(-5).ToWords(); // → "minus five"
5_000_000_000L.ToWords(); // → "five billion"
(-1_000_000_000_000L).ToWords(); // → "minus one trillion"

ToWords() works on whole numbers only. To write a money amount, split it into the whole part and the cents:

Invoice total on a cheque
decimal invoiceTotal = 1250.75m;
int dollars = (int)decimal.Truncate(invoiceTotal);
int cents = (int)((invoiceTotal - dollars) * 100);

string amountInWords = $"{dollars.ToWords()} dollars and {cents.ToWords()} cents";
// → "one thousand two hundred fifty dollars and seventy-five cents"

ToRoman​

ToRoman() writes an int from 1 to 3999 as a standard upper-case Roman numeral. Use it for chapter numbers, copyright years and list markers.

Roman numerals
2024.ToRoman(); // → "MMXXIV"
1994.ToRoman(); // → "MCMXCIV"
14.ToRoman(); // → "XIV"
4.ToRoman(); // → "IV"
3999.ToRoman(); // → "MMMCMXCIX"

A number outside 1–3999 (including 0 and negative numbers) throws ArgumentOutOfRangeException with the parameter name number.

tip

To read a numeral back, use FromRoman: 2024.ToRoman().FromRoman() returns 2024. The round trip is exact for every number from 1 to 3999.

Sizes and short numbers​

ToFileSize​

ToFileSize() formats a byte count with 1024-based units: B, KB, MB, GB, TB, PB and EB. It keeps one decimal by default and drops trailing zeros. There is an int overload, so bytes.Length.ToFileSize() works without a cast.

Upload sizes
byte[] attachment = new byte[3_500_000];
string size = attachment.Length.ToFileSize(); // → "3.3 MB"

0L.ToFileSize(); // → "0 B"
512L.ToFileSize(); // → "512 B"
1023L.ToFileSize(); // → "1023 B"
1024L.ToFileSize(); // → "1 KB"
1536L.ToFileSize(); // → "1.5 KB"
1_048_576L.ToFileSize(); // → "1 MB"
1_073_741_824L.ToFileSize(); // → "1 GB"
long.MaxValue.ToFileSize(); // → "8 EB"

Pass decimals to choose the maximum number of decimals, from 0 to 15. Trailing zeros are still dropped.

Choosing the precision
3_500_000.ToFileSize(2); // → "3.34 MB"
1_342_177_280L.ToFileSize(2); // → "1.25 GB"
1536L.ToFileSize(0); // → "2 KB"
1024L.ToFileSize(3); // → "1 KB"

Negative sizes keep their sign: (-1536L).ToFileSize() returns "-1.5 KB". A decimals value outside 0–15 throws ArgumentOutOfRangeException with the parameter name decimals.

note

The units are 1024-based (1 KB = 1024 bytes) but use the common labels KB, MB and GB, not KiB, MiB and GiB. To read a size back into a byte count, use ToByteCount.

ToCompactString​

ToCompactString() shortens a large int or long for dashboards, badges and charts. It uses 1000-based suffixes (K, M, B, T, Qa for 10^15, Qi for 10^18) and at most one decimal, rounded half away from zero.

Follower and view counts
int followers = 12_480;
string badge = followers.ToCompactString(); // → "12.5K"

950.ToCompactString(); // → "950"
1000.ToCompactString(); // → "1K"
1049.ToCompactString(); // → "1K"
1250.ToCompactString(); // → "1.3K"
999_949.ToCompactString(); // → "999.9K"
999_950.ToCompactString(); // → "1M"
1_250_000.ToCompactString(); // → "1.3M"
3_000_000_000L.ToCompactString(); // → "3B"
4_000_000_000_000L.ToCompactString(); // → "4T"
1_500_000_000_000_000L.ToCompactString(); // → "1.5Qa"
long.MaxValue.ToCompactString(); // → "9.2Qi"
(-1500).ToCompactString(); // → "-1.5K"

The suffix moves up when rounding reaches 1000: 999_950 is "1M", not "1000K". To read a compact number back, use FromCompactString. Values that lost a digit, such as "1.3M", read back as 1300000.

Rounding and fractions​

RoundTo​

RoundTo(step) rounds to the nearest multiple of step. Halves round away from zero. Use it to round prices to 0.05, quantities to packs of 6 or minutes to a quarter hour. It has overloads for decimal, double, int and long. The step has the same type as the value.

Prices, packs and quarter hours
decimal price = 19.97m;
decimal cashPrice = price.RoundTo(0.05m); // → 19.95m

17m.RoundTo(5m); // → 15m
17.5m.RoundTo(5m); // → 20m
(-17.5m).RoundTo(5m); // → -20m

int bottles = 17;
int bottlesToShip = bottles.RoundTo(6); // → 18
15.RoundTo(10); // → 20
(-15).RoundTo(10); // → -20
(-14).RoundTo(10); // → -10

1_234_567L.RoundTo(1000L); // → 1235000

double minutes = 52.0;
double billedMinutes = minutes.RoundTo(15.0); // → 45
53.0.RoundTo(15.0); // → 60

The decimal result keeps the step's decimals when that does not change the value. 19.974m.RoundTo(0.05m) prints as 19.95 and 19.975m.RoundTo(0.05m) prints as 20.00.

tip

RoundTo rounds halves away from zero, unlike Math.Round, which uses banker's rounding by default. 2.5m.RoundTo(1m) returns 3, while Math.Round(2.5m) returns 2.

warning

Use the decimal overload for money. Binary floating point cannot represent 0.05 exactly, so 19.97.RoundTo(0.05) returns 19.950000000000003.

Errors and edge cases:

  • A step that is zero or negative throws ArgumentOutOfRangeException with the parameter name step. For double, a NaN or infinite step throws too.
  • The int, long and decimal overloads throw OverflowException when the nearest multiple does not fit the type, for example int.MaxValue.RoundTo(10) or decimal.MaxValue.RoundTo(10m).
  • The double overload returns NaN and infinite values unchanged. It does not throw on overflow. It returns infinity only when the nearest multiple is too large for a double.

To round a DateTime or a TimeSpan to an interval, see Dates and Durations.

ToFraction​

ToFraction() writes a double or decimal as the closest mixed fraction in lowest terms. By default the denominator is at most 1000. Whole numbers have no fraction part. Use it for recipes, measurements and imperial sizes.

Recipe quantities
0.75.ToFraction(); // → "3/4"
1.75.ToFraction(); // → "1 3/4"
2.5.ToFraction(); // → "2 1/2"
0.125.ToFraction(); // → "1/8"
2.0.ToFraction(); // → "2"
0.0.ToFraction(); // → "0"
(-0.5).ToFraction(); // → "-1/2"
(-2.25).ToFraction(); // → "-2 1/4"
0.3333.ToFraction(); // → "1/3"
3.14159.ToFraction(); // → "3 16/113"
(0.1 + 0.2).ToFraction(); // → "3/10"

decimal cups = 2.375m;
string label = $"{cups.ToFraction()} cups flour"; // → "2 3/8 cups flour"

Pass maxDenominator to limit the denominator. A small limit gives a simpler but less exact fraction. A limit of 1 rounds to a whole number.

Limiting the denominator
3.14159.ToFraction(10); // → "3 1/7"
3.14159.ToFraction(100); // → "3 14/99"
3.14159.ToFraction(1000); // → "3 16/113"

0.333.ToFraction(); // → "333/1000"
0.333.ToFraction(16); // → "1/3"

0.9999.ToFraction(10); // → "1"
0.75.ToFraction(1); // → "1"
0.25.ToFraction(1); // → "0"

Values very close to a whole number round to it. 0.0004.ToFraction() returns "0" and 0.9999.ToFraction() returns "1", because no fraction with a denominator up to 1000 is closer.

Errors and culture:

  • A NaN or infinite double throws ArgumentException with the parameter name value.
  • A maxDenominator less than 1 throws ArgumentOutOfRangeException with the parameter name maxDenominator.
  • The output uses the invariant culture with no group separators. Under de-DE, (-1234.5).ToFraction() still returns "-1234 1/2". Negative zero returns "0".

Arithmetic helpers​

IsEven / IsOdd​

IsEven() and IsOdd() check the parity of an int or long. Zero is even, and negative numbers work as expected.

Striped table rows
int rowIndex = 4;
string rowClass = rowIndex.IsEven() ? "row-even" : "row-odd"; // → "row-even"

0.IsEven(); // → true
(-4).IsEven(); // → true
(-3).IsOdd(); // → true
int.MaxValue.IsOdd(); // → true
int.MinValue.IsEven(); // → true
7L.IsOdd(); // → true

PercentOf​

value.PercentOf(total) returns value / total * 100. The result is not rounded. The int overload returns a double, so integer division does not cut off the fraction. The decimal and double overloads return their own type.

Progress and discounts
int completedTasks = 18;
int totalTasks = 24;
double progress = completedTasks.PercentOf(totalTasks); // → 75

25.PercentOf(200); // → 12.5
300.PercentOf(200); // → 150
1.PercentOf(3); // → 33.33333333333333
Math.Round(1.PercentOf(3), 1); // → 33.3

decimal discount = 15m;
decimal listPrice = 60m;
decimal discountPercent = discount.PercentOf(listPrice); // → 25.00m

0.5.PercentOf(2.0); // → 25

A total of zero throws ArgumentOutOfRangeException with the parameter name total. A value larger than the total gives more than 100, and a negative total gives a negative result: 50.PercentOf(-200) returns -25.

Digits / DigitSum​

Digits() returns the decimal digits of an int or long as an IReadOnlyList<int>, most significant digit first. DigitSum() adds them up. Both ignore the sign. Use them for check digits, digit puzzles and validation rules.

Digits of an invoice number
int invoiceNumber = 40721;
IReadOnlyList<int> digits = invoiceNumber.Digits(); // → [4, 0, 7, 2, 1]
int checksum = invoiceNumber.DigitSum(); // → 14

1234.Digits(); // → [1, 2, 3, 4]
1000.Digits(); // → [1, 0, 0, 0]
(-907).Digits(); // → [9, 0, 7]
0.Digits(); // → [0]

1234.DigitSum(); // → 10
(-99).DigitSum(); // → 18
0.DigitSum(); // → 0

int.MinValue and long.MinValue work too: long.MinValue.DigitSum() returns 89.

Rescale​

value.Rescale(fromMin, fromMax, toMin, toMax) maps a double linearly from one range to another. fromMin maps to toMin and fromMax maps to toMax. Use it for unit conversions, progress bars, chart axes and score normalization.

Ratings, temperatures and progress bars
int stars = 4;
double score = ((double)stars).Rescale(1, 5, 0, 100); // → 75

double celsius = 37.0;
double fahrenheit = celsius.Rescale(0, 100, 32, 212); // → 98.6

0.5.Rescale(0, 1, 0, 100); // → 50
0.25.Rescale(0, 1, 100, 0); // → 75
5.0.Rescale(10, 0, 0, 1); // → 0.5

A reversed range works in either position, as the last two lines show.

Values outside the source range are extrapolated, not limited. Chain Clamp when the result must stay in the target range:

Keep a progress bar between 0 and 100
2.0.Rescale(0, 1, 0, 100); // → 200
2.0.Rescale(0, 1, 0, 100).Clamp(0.0, 100.0); // → 100

An empty source range (fromMin equal to fromMax) throws ArgumentException with the parameter name fromMax.

Comparing and limiting values​

IsBetween​

value.IsBetween(minInclusiveValue, maxInclusiveValue) checks that a value lies between two bounds. Both bounds are included. It works on any IComparable<T>.

Age and date checks
int age = 30;
bool eligible = age.IsBetween(18, 65); // → true

1.IsBetween(1, 10); // → true
10.IsBetween(1, 10); // → true
0.IsBetween(1, 10); // → false
11.IsBetween(1, 10); // → false

DateTime orderDate = new DateTime(2024, 5, 15);
DateTime fiscalYearStart = new DateTime(2024, 1, 1);
DateTime fiscalYearEnd = new DateTime(2024, 12, 31);
bool inFiscalYear = orderDate.IsBetween(fiscalYearStart, fiscalYearEnd); // → true

A null value throws ArgumentNullException with the parameter name value. The bounds are not checked: if the minimum is greater than the maximum, no value is between them, so 5.IsBetween(10, 1) returns false.

note

IsBetween includes both ends. Includes on a tuple includes the start but not the end. Pick the one that matches your rule.

Clamp​

value.Clamp(min, max) returns min when the value is below it, max when it is above it, and the value otherwise. Unlike Math.Clamp, it works on any IComparable<T>, including dates, strings and versions.

Limiting quantities, dates and versions
int requestedQuantity = 150;
int quantity = requestedQuantity.Clamp(0, 100); // → 100

(-5).Clamp(0, 100); // → 0
50.Clamp(0, 100); // → 50

DateTime earliestDelivery = new DateTime(2024, 6, 3);
DateTime latestDelivery = new DateTime(2024, 6, 28);
DateTime requestedDelivery = new DateTime(2024, 7, 15);
DateTime delivery = requestedDelivery.Clamp(earliestDelivery, latestDelivery); // → 2024-06-28

Version clientVersion = new Version(3, 1);
Version apiVersion = clientVersion.Clamp(new Version(1, 0), new Version(2, 5)); // → 2.5

"m".Clamp("a", "f"); // → "f"

Errors:

  • A null value throws ArgumentNullException with the parameter name value. A null minimum throws with the parameter name min.
  • A minimum greater than the maximum throws ArgumentException with the parameter name min: 5.Clamp(10, 1) throws.

string values compare with string.CompareTo, which is culture-sensitive. Use Clamp on strings only when that ordering is what you want.

Half-open ranges on tuples​

Overlaps and Includes treat a (Start, End) tuple as a half-open range: the start is included and the end is not. This matches how bookings and time slots usually work. A meeting from 9:00 to 14:00 does not clash with one that starts at 14:00. A range whose start equals its end is empty.

The tuple element names do not matter. (booking.Start, booking.End), (9, 17) and a variable of type (DateTime Start, DateTime End) all work. To loop over a C# range such as 0..10, see foreach and deconstruction.

Overlaps​

range.Overlaps(other) returns true when two ranges share any part. Ranges that only touch at one end do not overlap, and an empty range overlaps nothing.

Room booking conflicts
DateTime nine = new DateTime(2024, 5, 1, 9, 0, 0);
DateTime one = new DateTime(2024, 5, 1, 13, 0, 0);
DateTime two = new DateTime(2024, 5, 1, 14, 0, 0);
DateTime five = new DateTime(2024, 5, 1, 17, 0, 0);

(DateTime Start, DateTime End) morningBooking = (nine, two);
(DateTime Start, DateTime End) afternoonBooking = (two, five);
(DateTime Start, DateTime End) lateLunchBooking = (one, five);

morningBooking.Overlaps(afternoonBooking); // → false (they touch at 14:00)
morningBooking.Overlaps(lateLunchBooking); // → true (they share 13:00–14:00)
Integer ranges
(1, 5).Overlaps((3, 8)); // → true
(1, 10).Overlaps((3, 5)); // → true (one contains the other)
(1, 5).Overlaps((1, 5)); // → true
(1, 5).Overlaps((5, 8)); // → false (they touch)
(1, 3).Overlaps((6, 8)); // → false
(3, 3).Overlaps((1, 5)); // → false (empty range)

It also works with TimeSpan time-of-day slots:

Shift cover
(TimeSpan Start, TimeSpan End) morningShift = (TimeSpan.FromHours(8), TimeSpan.FromHours(12));
(TimeSpan Start, TimeSpan End) brunchService = (TimeSpan.FromHours(11), TimeSpan.FromHours(13));

bool needsCover = morningShift.Overlaps(brunchService); // → true

A range that ends before it starts throws ArgumentException. The parameter name is range for the receiver and other for the argument. A null bound, for example in a (string Start, string End) tuple, throws ArgumentException in the same way.

Includes​

range.Includes(value) returns true when Start <= value < End. Use it for opening hours, billing periods and any rule where the end belongs to the next period.

Office hours and billing periods
(int Start, int End) officeHours = (9, 17);

officeHours.Includes(9); // → true
officeHours.Includes(16); // → true
officeHours.Includes(17); // → false (the end is excluded)
officeHours.Includes(8); // → false

(DateTime Start, DateTime End) january = (new DateTime(2024, 1, 1), new DateTime(2024, 2, 1));
january.Includes(new DateTime(2024, 1, 31, 23, 59, 59)); // → true
january.Includes(new DateTime(2024, 2, 1)); // → false

(5, 5).Includes(5); // → false (empty range)

A null value throws ArgumentNullException with the parameter name value. A range that ends before it starts throws ArgumentException with the parameter name range.

tip

Write a month as (firstDay, firstDayOfNextMonth), not (firstDay, lastDay 23:59:59). With a half-open range, every moment of the month is included and consecutive months never overlap.

See also​

  • Statistics: median, percentiles and standard deviation for numeric sequences.
  • String parsing: ToByteCount and FromCompactString read the text that ToFileSize and ToCompactString write.
  • Text and humanize: FromRoman, pluralization and other text for people.
  • Dates: rounding dates to an interval and working with date ranges.