Skip to content

agdgb/HumanNumbers

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

56 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

HumanNumbers

Human-readable numbers for .NET — a governed, high-performance, API-safe formatting engine.

NuGet License: MIT


⚡ Quick Start

using HumanNumbers;

1500.ToHuman();        // "1.50K"
1500000.ToHuman();     // "1.50M"

🎯 Why HumanNumbers?

Formatting numbers correctly at scale is deceptively complex. Naive implementations often:

  • Break at rounding boundaries: (e.g., 999,499 rounding to 1,000K instead of 1.00M).
  • Allocate excessively: Creating intermediate strings in hot telemetry or logging paths.
  • Lack consistency: Different services formatting the same data in conflicting ways.

HumanNumbers provides a governed, high-performance platform for number presentation that prioritizes:

  • Correctness: Smart suffix promotion logic that handles rounding boundaries gracefully.
    999499m.ToHuman();                                      // "1.00M" (Readable)
    999499m.ToHuman(HumanNumberFormatOptions.StrictPreset); // "999.50K" (Accurate)
  • Performance: Optimized Span<char>-based paths and zero-allocation parsing.
  • Safety: Non-intrusive ASP.NET Core integration that preserves API contracts by default.
  • Governance: A central Policy system to ensure consistent formatting across distributed services.

🚀 Comparison: Three Ways to Format

Approach Code Trade-off
Naive .NET (val / 1000).ToString("F2") + "K" Error-prone at thresholds, high allocation, no culture support.
Fluent API val.ToHuman(2) Balanced. Minimal allocation with full formatting support.
Span API val.ToHuman(span, out _) High-Performance. Zero-allocation; ideal for telemetry and logging.

Note

The "Naive" example reflects common real-world implementations, not optimized hand-written formatters. Both APIs use the same high-performance core engine; the Span overload simply bypasses string materialization for critical paths.


🔢 Core Formatting Engine

Readable vs. Strict Promotion

By default, we prioritize readability. If a number rounds up to the next threshold (e.g., 999,499 to 1.00M), we promote the suffix. For audit-heavy scenarios, use Strict Mode:

999499m.ToHuman();                                      // "1.00M" (Readable Default)
999499m.ToHuman(HumanNumberFormatOptions.StrictPreset); // "999.50K" (Strict Accuracy)

Specialized Units

  • Financial: 1234.56m.ToHumanWords(); // "One Thousand Two Hundred..."
  • Bytes: 1024L.ToHumanBytes(); // "1.00 KiB" (Supports Binary/Decimal)
  • Fractions: 1.5m.ToHumanFraction(32); // "1 16/32"
  • Roman: 2024.ToRoman(); // "MMXXIV"

🌍 Internationalization & Global Support

HumanNumbers is built on top of the native .NET CultureInfo system, meaning it automatically respects global numbering rules, separators, and currency symbols out of the box.

Verified Global Outputs (Examples)

Culture Scaled Human Currency Format Non-Scaled Grouping Notes
ar-SA (Arabic) 1٫25M ر.س.‏ 1٫25M 1٬000٬000 Uses unique Arabic separators.
hi-IN (Hindi) 1.25M ₹1.25M 10,00,000 Lakh/Crore grouping (10,00,000).
fr-FR (French) 1,25M 1,25M € 1 000 000 Space separator and trailing currency.
de-DE (German) 1,25M 1,25M € 1.000.000 Comma decimal and dot grouping.
am-ET (Amharic) 1.25M Br1.25M 1,000,000 Custom Ethiopian currency symbol.

Custom Words (Financial I18n)

For non-English support in financial word-formatting (Check Writing), implement the IWordsProvider interface:

public class SpanishWordsProvider : IWordsProvider
{
    public string NegativeWord => "Negativo";
    public string ConjunctionWord => "con";
    public string ToWords(decimal value) => "Mil Doscientos"; 
}

// Usage
1200m.ToHumanWords(provider: new SpanishWordsProvider()); // "Mil Doscientos"

🚀 Modernization & New Features (v2.0.2)

We have significantly hardened and optimized the codebase with robust enterprise-grade features:

  • Pluggable Currency Dependency Injection (ICurrencyMappingProvider): Map custom regional or business keys (like "EastAfrica", "AsiaPacific") to ISO currency codes by registering a custom provider to the DI container:
    builder.Services.AddSingleton<ICurrencyMappingProvider, MyCustomMappingProvider>();
  • O(1) Pre-Cached Currency Lookups: High-latency culture scanning is gone! Currency symbols are fully pre-cached, turning regional scans into O(1) dictionary lookups.
  • O(log N) Suffix Lookups: Support for large custom suffix arrays with O(log N) binary search threshold selection, retaining fast linear scanning for standard <= 8 suffix arrays.
  • Thread-Safe Option Mutations: Shared options registration utilizes record with-expressions, preventing thread cross-contamination during concurrent executions.
  • Circular Reference Protection: Dynamic serialization filter protects APIs from recursion crashes by tracing visited nodes via identity-based reference sets.

🧩 Policy-Driven Formatting (Enterprise Ready)

Define formatting rules once and enforce them across your entire infrastructure. Ensure that APIs, dashboards, and reports share the same magnitude logic and rounding rules.

// Setup central governance
builder.Services.AddHumanNumbersDefaults(options =>
{
    options.AddPolicy("Finance", new HumanNumberFormatOptions
    {
        DecimalPlaces = 2,
        PromotionThreshold = 1.0m // strict mode: 999,499 -> "999.50K"
    });
});

// Apply consistently across services
HumanNumber.Format(value).UsingPolicy("Finance").ToHuman();

🌍 Multi-Culture & Custom Magnitude Examples

The policy system handles unique numbering systems (like 10,000-based scaling) with ease:

builder.Services.AddHumanNumbersDefaults(options =>
{
    // Indian System: Lakhs (10^5) and Crores (10^7)
    options.AddPolicy("Indian", new HumanNumberFormatOptions
    {
        CachedCustomSuffixes = new[] {
            new MagnitudeSuffix(1000m, "K"),
            new MagnitudeSuffix(100_000m, "Lakh"),
            new MagnitudeSuffix(10_000_000m, "Crore")
        },
        Threshold = 1000m,
        DecimalPlaces = 2
    });

    // Chinese System: Wàn (10^4) and Yì (10^8)
    options.AddPolicy("Chinese", new HumanNumberFormatOptions
    {
        CachedCustomSuffixes = new[] {
            new MagnitudeSuffix(10_000m, "Wàn"),
            new MagnitudeSuffix(100_000_000m, "Yì")
        },
        Threshold = 10_000m
    });
});

// Usage
120000m.ToHuman(HumanNumbersConfig.Instance.GetPolicies()["Indian"]); // "1.20 Lakh"
120000m.ToHuman(HumanNumbersConfig.Instance.GetPolicies()["Chinese"]); // "12.00 Wàn"

Tip

Use CachedCustomSuffixes for non-standard scaling (like 10^4 or 10^5). For standard 10^3 scaling, simply use CustomSuffixes.


🚀 Real-World Scenario: Zero-Allocation Telemetry

In high-frequency logging or telemetry, even small string allocations can trigger GC pressure. HumanNumbers allows you to format directly into reusable buffers:

// Reusable buffer (stack or ArrayPool)
Span<char> buffer = stackalloc char[32];

if (largeValue.ToHuman(buffer, out var written))
{
    // Log directly from the span without creating a string
    logger.LogInformation("Metric: {Value}", buffer[..written]);
}

📊 Performance & Engineering Nuance

We benchmark against "Naive" implementations to provide an honest look at the costs of convenience.

Operation Latency Allocated Memory Engineering Note
Manual Concatenation ~60-80 ns 64-80 B Standard ToString() + "K" approach.
ToHuman() ~160-180 ns 56 B Balanced. Uses less memory than naive concatenation.
ToHuman (Span) ~150-170 ns 24 B Optimized. Significant memory reduction via Span paths.
TryParse ~40-50 ns 0 B Zero-Alloc. Fully allocation-free parsing.

Note

Latency figures are environment-dependent. The "Naive" example reflects common real-world implementations, not optimized hand-written formatters.


🌐 ASP.NET Core: Safe by Default

Many libraries implicitly change JSON output globally, breaking API contracts. HumanNumbers is designed to be non-intrusive.

Important

By default, HumanNumbers does not modify JSON output unless explicitly enabled via attributes or result extensions.

1. Register Policies

builder.Services.AddHumanNumbersDefaults(options => {
    options.AddPolicy("Compact", new HumanNumberFormatOptions { DecimalPlaces = 1 });
});

2. Selective Serialization

Control exactly what the client sees without breaking your DTOs.

public class AnalyticsDto {
    public decimal RawValue { get; set; } // JSON: 1500000

    [HumanNumber(OutputMode = HumanNumberOutputMode.SerializeAsHuman)]
    public decimal DisplayValue { get; set; } // JSON: "1.50M"
}

3. Explicit Transformations

In Minimal APIs, use HumanOk to trigger the transformation only when intended:

app.MapGet("/stats", () => Results.Extensions.HumanOk(new { Revenue = 1500000 }));

🧠 Design Philosophy

  • Predictable Performance: Every feature has a known and stable allocation profile.
  • Governance First: Use the Policy system to define "Brand Guidelines" for numbers once, then apply them everywhere.
  • Allocation Aware: We leverage Span<char> and ISpanFormattable to ensure that adding "humanity" to your data doesn't sink your GC performance.
  • Contract Safety: Your API types remain decimal. We only change the representation during the final serialization step.


🔄 Migration from NumberFormatter

HumanNumbers is the official successor to the legacy NumberFormatter package. It features a modernized API, significantly improved performance (via Span<char>), and a unified policy system.

Key Changes

  • Namespace: NumberFormatterHumanNumbers
  • Method Renaming:
    • ToShortString()ToHuman()
    • ToShortCurrencyString()ToHumanCurrency()
  • Binary Compatibility: A legacy shim is provided in the NumberFormatter namespace (marked as [Obsolete]) to help with a zero-friction transition.
// Legacy (NumberFormatter)
using NumberFormatter;
1500.ToShortString(); 

// Modern (HumanNumbers)
using HumanNumbers;
1500.ToHuman();

📦 Installation

dotnet add package HumanNumbers
dotnet add package HumanNumbers.AspNetCore

🙌 Contributing & License

Licensed under MIT. Contributions are welcome via Issues and Pull Requests.


📈 Appendix: Detailed Benchmarks

Detailed benchmarks below reflect full production runs and may differ slightly from summarized figures above. All results were generated using BenchmarkDotNet v0.14.0 on .NET 10.0 (X64 RyuJIT).

Method Scenario / Input Mean Gen 0 Allocated Engineering Context
StandardScaled Naive (999,499) 78.33 ns 0.0026 80 B Naive ToString + Concat approach.
ToHuman Governed (999,499) 172.79 ns 0.0024 56 B 30% less memory than naive.
ToHuman (Span) Governed (999,499) 152.09 ns 0.0007 24 B Optimized Span path.
TryParse $1.50M 50.86 ns - 0 B Zero-Alloc parsing.
ToHumanBytes 1024 Bytes 56.62 ns 0.0013 40 B Single materialized string.
ToHumanWords 1234.56 322.15 ns 0.0186 560 B Non-recursive assembly.
ToRoman 2024 31.79 ns 0.0013 40 B Optimized buffer path.

Key Takeaway: While HumanNumbers handles complex thresholds and rounding logic that manual code often misses, it does so with a smaller memory footprint than naive string concatenation approaches.


🔎 Keywords

human-readable numbers, number formatting, suffix formatting, K/M/B formatting, span formatting, zero allocation, .NET performance, telemetry formatting, financial formatting, magnitude formatting.

About

Human-readable numbers for .NET — a high-performance, governed, and culture-aware formatting engine. Converts numbers to compact forms (K/M/B), financial words, bytes, roman numerals, and more with zero-allocation Span paths.

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors