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
| Method | What it does |
|---|---|
ToOrdinal | Writes an English ordinal with digits: "1st", "22nd", "113th". |
ToOrdinalWords | Writes an English ordinal in words: "first", "twenty-first", "one hundredth". |
ToWords | Writes a number in English words: "one hundred twenty-three". |
ToRoman | Writes a number from 1 to 3999 as a Roman numeral: "MCMXCIV". |
ToFileSize | Formats a byte count with 1024-based units: "1.5 KB", "3.3 MB". |
ToCompactString | Shortens a large count: "1.5K", "1.3M", "3B". |
RoundTo | Rounds to the nearest multiple of a step, halves away from zero. |
ToFraction | Writes a number as the closest mixed fraction: "1 3/4". |
IsEven / IsOdd | Checks the parity of an int or long. |
PercentOf | Returns which percentage of a total a value is. |
Digits / DigitSum | Returns the decimal digits of a number, or their sum. |
Rescale | Maps a value linearly from one range to another. |
IsBetween | Checks that a value lies between two bounds, both inclusive. |
Clamp | Limits a value to a minimum and a maximum. |
Overlaps | Checks whether two half-open (Start, End) ranges share any part. |
Includes | Checks 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.
| Method | int | long | decimal | double |
|---|---|---|---|---|
ToOrdinal() | yes | no | no | no |
ToOrdinalWords() | yes | no | no | no |
ToWords() | yes | yes | no | no |
ToRoman() | yes | no | no | no |
ToFileSize(), ToFileSize(int decimals) | yes | yes | no | no |
ToCompactString() | yes | yes | no | no |
RoundTo(step) | yes | yes | yes | yes |
ToFraction(), ToFraction(int maxDenominator) | no | no | yes | yes |
IsEven(), IsOdd() | yes | yes | no | no |
PercentOf(total) | yes (returns double) | no | yes | yes |
Digits(), DigitSum() | yes | yes | no | no |
Rescale(...) | no | no | no | yes |
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".
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".
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"
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.
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:
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
ArgumentOutOfRangeExceptionwith the parameter namestep. Fordouble, a NaN or infinite step throws too. - The
int,longanddecimaloverloads throwOverflowExceptionwhen the nearest multiple does not fit the type, for exampleint.MaxValue.RoundTo(10)ordecimal.MaxValue.RoundTo(10m). - The
doubleoverload returns NaN and infinite values unchanged. It does not throw on overflow. It returns infinity only when the nearest multiple is too large for adouble.
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.
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.
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
doublethrowsArgumentExceptionwith the parameter namevalue. - A
maxDenominatorless than 1 throwsArgumentOutOfRangeExceptionwith the parameter namemaxDenominator. - 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.
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.
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.
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.
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:
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>.
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.
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.
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
nullvalue throwsArgumentNullExceptionwith the parameter namevalue. Anullminimum throws with the parameter namemin. - A minimum greater than the maximum throws
ArgumentExceptionwith the parameter namemin: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.
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)
(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:
(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.
(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.
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:
ToByteCountandFromCompactStringread the text thatToFileSizeandToCompactStringwrite. - Text and humanize:
FromRoman, pluralization and other text for people. - Dates: rounding dates to an interval and working with date ranges.