Інтернаціоналізація

Humanizer: людиномовні рядки у .NET

Повний огляд бібліотеки Humanizer для .NET: перетворення TimeSpan і DateTime у читабельні рядки ('2 години тому', 'через 3 дні'), відмінювання чисел і слів, форматування ByteSize, обробка enum, PascalCase/camelCase перетворення. Підтримка 67 мов включно з українською.

Humanizer: людиномовні рядки у .NET

Є клас задач, про які розробники рідко думають наперед, і якими потім займаються в останній момент: «Як написати '23 хвилини тому' замість 1716312000?», «Як перетворити UserProfileSettings на User profile settings для заголовку?», «Як написати 1 файл vs 5 файлів правильно?»

Humanizer вирішує саме ці задачі. Це бібліотека з відкритим кодом для .NET, що перетворює типи .NET — TimeSpan, DateTime, int, Enum, рядки — у зрозумілі людині форми. Замість написання власної логіки відмінювань, кейс-конвертацій і форматувань — підключаєте один NuGet та пишете виразний, читабельний код.

Humanizer підтримує 67 мов, включаючи українську. Це означає: 2.Hours().Humanize(culture: new CultureInfo("uk")) поверне "2 години", а DateTime.UtcNow.AddDays(-3).Humanize(culture: new CultureInfo("uk"))"3 дні тому".

Стаття написана як окреме доповнення до матеріалу про інтернаціоналізацію. Humanizer — це не заміна IStringLocalizer, а доповнення: він береться за читабельне форматування типів .NET, тоді як IStringLocalizer відповідає за переклад бізнес-текстів.

Встановлення

dotnet add package Humanizer

Для конкретних мов можна встановити окремий пакет з лише потрібними ресурсами (менший розмір):

dotnet add package Humanizer.Core         # Тільки Humanizer без перекладів
dotnet add package Humanizer              # Humanizer + всі 67 мов

Після встановлення жодної реєстрації у DI не потрібно — всі методи Humanizer є методами розширення і доступні після using Humanizer;.


Частина 1. Humanize(): коли було і коли буде

DateTime.Humanize() — відносний час

Найпопулярніша функція Humanizer: перетворення DateTime у читабельний рядок відносного часу.

TimeExamples.cs
using Humanizer;
using System.Globalization;

// ─── Минулий час ───────────────────────────────────────────────
DateTime.UtcNow.AddSeconds(-30).Humanize()          // "30 seconds ago"
DateTime.UtcNow.AddMinutes(-2).Humanize()           // "2 minutes ago"
DateTime.UtcNow.AddHours(-1).Humanize()             // "an hour ago"
DateTime.UtcNow.AddHours(-5).Humanize()             // "5 hours ago"
DateTime.UtcNow.AddDays(-1).Humanize()              // "yesterday"
DateTime.UtcNow.AddDays(-3).Humanize()              // "3 days ago"
DateTime.UtcNow.AddMonths(-2).Humanize()            // "2 months ago"
DateTime.UtcNow.AddYears(-1).Humanize()             // "one year ago"
DateTime.UtcNow.AddYears(-5).Humanize()             // "5 years ago"

// ─── Майбутній час ─────────────────────────────────────────────
DateTime.UtcNow.AddSeconds(45).Humanize()           // "45 seconds from now"
DateTime.UtcNow.AddMinutes(10).Humanize()           // "10 minutes from now"
DateTime.UtcNow.AddHours(3).Humanize()              // "3 hours from now"
DateTime.UtcNow.AddDays(1).Humanize()               // "tomorrow"
DateTime.UtcNow.AddDays(7).Humanize()               // "7 days from now"

// ─── Українська мова ───────────────────────────────────────────
var uk = new CultureInfo("uk");
DateTime.UtcNow.AddMinutes(-2).Humanize(culture: uk)    // "2 хвилини тому"
DateTime.UtcNow.AddHours(-1).Humanize(culture: uk)      // "годину тому"
DateTime.UtcNow.AddDays(-1).Humanize(culture: uk)       // "вчора"
DateTime.UtcNow.AddDays(-5).Humanize(culture: uk)       // "5 днів тому"
DateTime.UtcNow.AddMonths(-1).Humanize(culture: uk)     // "місяць тому"

// ─── Польська мова ─────────────────────────────────────────────
var pl = new CultureInfo("pl");
DateTime.UtcNow.AddMinutes(-2).Humanize(culture: pl)    // "2 minuty temu"
DateTime.UtcNow.AddDays(-3).Humanize(culture: pl)       // "3 dni temu"

Humanizer інтелектуально обирає форму: "an hour ago" замість "1 hour ago", "yesterday" замість "1 day ago". З параметром culture він додатково застосовує правила відмінювання мови.

DateTime.Humanize() з власною точкою відліку

За замовчуванням порівняння відбувається відносно DateTime.UtcNow. Але можна задати власну точку відліку:

var publishedAt = new DateTime(2024, 01, 15, 12, 0, 0, DateTimeKind.Utc);
var comparedTo  = new DateTime(2024, 01, 20, 12, 0, 0, DateTimeKind.Utc);

publishedAt.Humanize(dateToCompareAgainst: comparedTo)  // "5 days ago"
// (відносно comparedTo, а не DateTime.UtcNow)

Це корисно для стрічок активності де кожен запис відображається відносно певного контексту, а не поточного часу.

TimeSpan.Humanize() — тривалості

Для форматування тривалості використовується TimeSpan.Humanize():

TimeSpan.FromSeconds(1).Humanize()          // "1 second"
TimeSpan.FromSeconds(90).Humanize()         // "1 minute"
TimeSpan.FromMinutes(5).Humanize()          // "5 minutes"
TimeSpan.FromHours(2).Humanize()            // "2 hours"
TimeSpan.FromDays(1).Humanize()             // "1 day"
TimeSpan.FromDays(30).Humanize()            // "4 weeks"

// Деталізація через параметр precision (скільки одиниць показувати)
TimeSpan.FromMinutes(95).Humanize(precision: 1)   // "1 hour"
TimeSpan.FromMinutes(95).Humanize(precision: 2)   // "1 hour, 35 minutes"

TimeSpan.FromHours(26).Humanize(precision: 1)     // "1 day"
TimeSpan.FromHours(26).Humanize(precision: 2)     // "1 day, 2 hours"

// Максимальна деталізація
TimeSpan.FromMinutes(125).Humanize(precision: 3)  // "2 hours, 5 minutes"
// Українська — правильне відмінювання
var uk = new CultureInfo("uk");
TimeSpan.FromMinutes(5).Humanize(culture: uk)         // "5 хвилин"
TimeSpan.FromMinutes(1).Humanize(culture: uk)         // "1 хвилина"
TimeSpan.FromMinutes(21).Humanize(culture: uk)        // "21 хвилина"
TimeSpan.FromHours(3).Humanize(culture: uk)           // "3 години"
TimeSpan.FromHours(11).Humanize(culture: uk)          // "11 годин"
TimeSpan.FromDays(1).Humanize(culture: uk)            // "1 день"
TimeSpan.FromDays(5).Humanize(culture: uk)            // "5 днів"
TimeSpan.FromDays(2).Humanize(precision: 2, culture: uk)  // "2 дні, 0 годин"

Відмінювання 1 хвилина / 2 хвилини / 5 хвилин — класична задача для Humanizer. Без нього це десятки умов у коді.

TimeUnit.Humanize() — конвертер одиниць часу

// Fluent API для побудови TimeSpan
2.Hours()               // TimeSpan.FromHours(2)
30.Minutes()            // TimeSpan.FromMinutes(30)
1.Days()                // TimeSpan.FromDays(1)

// Використання у поєднанні:
var deadline = DateTime.UtcNow + 2.Hours() + 30.Minutes();

// Арифметика дат
var nextWeek     = DateTime.UtcNow + 7.Days();
var lastMonth    = DateTime.UtcNow - 1.Months();
var inTwoYears   = DateTime.UtcNow + 2.Years();

Ці методи роблять код більш виразним: 2.Hours() читається значно краще ніж TimeSpan.FromHours(2).


Частина 2. Числа та слова: відмінювання

ToWords() — число словами

1.ToWords()       // "one"
10.ToWords()      // "ten"
42.ToWords()      // "forty-two"
100.ToWords()     // "one hundred"
1000.ToWords()    // "one thousand"
1_000_000.ToWords()  // "one million"

// Від'ємні числа
(-3).ToWords()    // "minus three"

// Великі числа
1_234_567.ToWords()  // "one million two hundred and thirty-four thousand five hundred and sixty-seven"
// Українська
var uk = new CultureInfo("uk");
1.ToWords(uk)     // "один"
5.ToWords(uk)     // "п'ять"
21.ToWords(uk)    // "двадцять один"
100.ToWords(uk)   // "сто"
1000.ToWords(uk)  // "одна тисяча"
(-7).ToWords(uk)  // "мінус сім"

ToOrdinalWords() — порядкові числівники

1.ToOrdinalWords()    // "first"
2.ToOrdinalWords()    // "second"
3.ToOrdinalWords()    // "third"
10.ToOrdinalWords()   // "tenth"
21.ToOrdinalWords()   // "twenty-first"
100.ToOrdinalWords()  // "one hundredth"
// Українська
var uk = new CultureInfo("uk");
1.ToOrdinalWords(uk)   // "перший"
2.ToOrdinalWords(uk)   // "другий"
5.ToOrdinalWords(uk)   // "п'ятий"
21.ToOrdinalWords(uk)  // "двадцять перший"

Ordinalize() — числовий суфікс

// Для en: 1st, 2nd, 3rd, 4th...
1.Ordinalize()    // "1st"
2.Ordinalize()    // "2nd"
3.Ordinalize()    // "3rd"
4.Ordinalize()    // "4th"
11.Ordinalize()   // "11th" (не "11st"!)
21.Ordinalize()   // "21st"

Pluralize() та Singularize() — однина і множина

// Автоматичне визначення множини (тільки англійська)
"file".Pluralize()       // "files"
"child".Pluralize()      // "children"
"person".Pluralize()     // "people"
"mouse".Pluralize()      // "mice"
"status".Pluralize()     // "statuses"
"category".Pluralize()   // "categories"

"files".Singularize()   // "file"
"children".Singularize() // "child"
"people".Singularize()  // "person"

Для динамічного відмінювання залежно від кількості:

// Format() з відмінюванням
int count = 5;
$"You have {count} {"message".ToQuantity(count)}"
// → "You have 5 messages"

count = 1;
$"You have {count} {"message".ToQuantity(count)}"
// → "You have 1 message"

// ToQuantity — ще зручніший API
"file".ToQuantity(1)    // "1 file"
"file".ToQuantity(5)    // "5 files"
"file".ToQuantity(0)    // "0 files"

// Зі словесним числівником
"file".ToQuantity(2, ShowQuantityAs.Words)
// → "two files"

Частина 3. Рядки: перетворення Case і регістрів

Перетворення між стилями іменування

Humanizer вміє конвертувати рядки між різними стилями іменування — незамінно при побудові API, відображенні полів форм, генерації документації.

// PascalCase / camelCase → читабельний текст
"PascalCaseString".Humanize()              // "Pascal case string"
"Underscored_input_string".Humanize()      // "Underscored input string"
"HTMLParser".Humanize()                    // "HTML parser"
"UserProfileSettings".Humanize()           // "User profile settings"
"createdAt".Humanize()                     // "Created at"

// Регістр першого слова
"some title".Humanize(LetterCasing.Title)  // "Some Title"
"some title".Humanize(LetterCasing.AllCaps)  // "SOME TITLE"
"some title".Humanize(LetterCasing.LowerCase) // "some title"

// ─── Зворотні перетворення ─────────────────────────────────────

// → PascalCase
"some string".Pascalize()                  // "SomeString"
"some-string".Pascalize()                  // "SomeString"

// → camelCase
"Some String".Camelize()                   // "someString"

// → underscore_case (snake_case)
"SomeProperty".Underscore()               // "some_property"
"Some string".Underscore()                // "some_string"

// → kebab-case
"SomeProperty".Kebaberize()               // "some-property"

// → Titleize
"some title here".Titleize()              // "Some Title Here"

// → Truncate
"Long string that needs truncating".Truncate(20)
// → "Long string that ne…"
"Long string that needs truncating".Truncate(20, "...")
// → "Long string that..."

Практичне використання

// Відображення назв властивостей у UI
var properties = typeof(UserProfile).GetProperties();
foreach (var prop in properties)
{
    var label = prop.Name.Humanize(LetterCasing.Title);
    // "FirstName" → "First Name"
    // "PhoneNumber" → "Phone Number"
    // "IsEmailVerified" → "Is Email Verified"
    Console.WriteLine($"{label}: ...");
}

Частина 4. Enum: читабельні значення

Одна з найчастіших задач — відображати значення enum не як "Pending", а як "В очікуванні" або "In Progress" замість "InProgress".

public enum OrderStatus
{
    Pending,
    InProgress,
    WaitingForPayment,
    Completed,
    Cancelled
}

// Автоматична гуманізація (PascalCase → читабельний рядок)
OrderStatus.Pending.Humanize()              // "Pending"
OrderStatus.InProgress.Humanize()           // "In progress"
OrderStatus.WaitingForPayment.Humanize()    // "Waiting for payment"
OrderStatus.Completed.Humanize()            // "Completed"

// Власне відображення через атрибут [Description]
using System.ComponentModel;
public enum OrderStatus
{
    [Description("Очікує обробки")]
    Pending,

    [Description("В обробці")]
    InProgress,

    [Description("Очікує оплати")]
    WaitingForPayment
}

OrderStatus.Pending.Humanize()              // "Очікує обробки"
OrderStatus.InProgress.Humanize()           // "В обробці"

// DehumanizeTo<T>() — зворотнє перетворення
"Pending".DehumanizeTo<OrderStatus>()       // OrderStatus.Pending
"In progress".DehumanizeTo<OrderStatus>()   // OrderStatus.InProgress

Атрибут [Description] — це стандартний атрибут з System.ComponentModel. Якщо Humanizer знаходить його — використовує його значення замість назви члена enum. Це ідеальне місце для локалізованих рядків.


Частина 5. ByteSize — розмір файлів

using Humanizer.Bytes;

// Побудова ByteSize
var size = ByteSize.FromBytes(1024);           // 1 KiB
var megabytes = ByteSize.FromMegabytes(2.5);   // 2.5 MB
var gigabytes = ByteSize.FromGigabytes(1);     // 1 GB

// Форматування
ByteSize.FromBytes(0).Humanize()              // "0 B"
ByteSize.FromBytes(512).Humanize()            // "512 B"
ByteSize.FromBytes(1024).Humanize()           // "1 KB"
ByteSize.FromBytes(1_500_000).Humanize()      // "1.43 MB"
ByteSize.FromBytes(2_500_000_000).Humanize()  // "2.33 GB"

// Власний формат
ByteSize.FromBytes(1_500_000).Humanize("MB")  // "1.43 MB"
ByteSize.FromBytes(1_500_000).Humanize("0.0 MB")  // "1.4 MB"

// Парсинг
ByteSize.Parse("1.5 GB")     // 1,610,612,736 bytes
ByteSize.Parse("500 MB")     // 524,288,000 bytes
ByteSize.TryParse("invalid", out _)  // false

Практичний приклад — відображення розміру завантаженого файлу:

app.MapPost("/upload", async (IFormFile file) =>
{
    var size = ByteSize.FromBytes(file.Length);
    return Results.Ok(new
    {
        fileName = file.FileName,
        size = size.Humanize(),  // "2.34 MB"
        sizeBytes = file.Length
    });
});

Частина 6. Інтеграція з ASP.NET Core Minimal API

Endpoint з повним набором Humanizer-функцій

Ось практичний приклад — API для стрічки активності блогу:

Features/Activity/ActivityEndpoints.cs
using Humanizer;
using System.Globalization;

namespace ShopApi.Features.Activity;

public record BlogPost(
    int Id,
    string Title,
    DateTime PublishedAt,
    long SizeBytes,
    int ViewCount,
    PostStatus Status);

public enum PostStatus
{
    [Description("Чернетка")]
    Draft,
    [Description("На модерації")]
    UnderReview,
    [Description("Опубліковано")]
    Published,
    [Description("Архівовано")]
    Archived
}

public static class ActivityEndpoints
{
    private static readonly List<BlogPost> _posts =
    [
        new(1, "Вступ до Minimal API",
            DateTime.UtcNow.AddMinutes(-45), 12_340, 1250, PostStatus.Published),
        new(2, "Humanizer у .NET",
            DateTime.UtcNow.AddHours(-3), 8_192, 87, PostStatus.Published),
        new(3, "Локалізація у ASP.NET Core",
            DateTime.UtcNow.AddDays(-2), 25_600, 312, PostStatus.Published),
        new(4, "Entity Framework Core",
            DateTime.UtcNow.AddDays(-14), 51_200, 4_029, PostStatus.Archived),
    ];

    public static WebApplication MapActivityEndpoints(this WebApplication app)
    {
        app.MapGet("/posts", GetPosts).WithTags("Posts");
        return app;
    }

    private static IResult GetPosts(string? lang = "uk")
    {
        var culture = lang switch
        {
            "uk" => new CultureInfo("uk"),
            "pl" => new CultureInfo("pl"),
            _    => new CultureInfo("en"),
        };

        var result = _posts.Select(post => new
        {
            post.Id,
            post.Title,

            // "45 хвилин тому" / "45 minutes ago"
            PublishedAgo = post.PublishedAt.Humanize(culture: culture),

            // "25.03.2024" — форматована дата за культурою
            PublishedDate = post.PublishedAt.ToString("d", culture),

            // "12.34 KB" / "25 KB" / "50 KB"
            Size = ByteSize.FromBytes(post.SizeBytes).Humanize(),

            // "1 250 переглядів" → через ToQuantity (en only для pluralize)
            // Для uk — власний формат:
            Views = culture.Name.StartsWith("uk")
                ? $"{post.ViewCount:N0} переглядів"
                : $"{"view".ToQuantity(post.ViewCount, ShowQuantityAs.Numeric)}",

            // "Опубліковано" (через [Description] атрибут)
            Status = post.Status.Humanize(),

            // "Published" → зворотньо через Dehumanize
            StatusCode = post.Status.ToString(),
        }).ToList();

        return Results.Ok(result);
    }
}

Відповідь для ?lang=uk:

[
  {
    "id": 1,
    "title": "Вступ до Minimal API",
    "publishedAgo": "45 хвилин тому",
    "publishedDate": "18.03.2024",
    "size": "12.06 KB",
    "views": "1 250 переглядів",
    "status": "Опубліковано",
    "statusCode": "Published"
  },
  {
    "id": 4,
    "title": "Entity Framework Core",
    "publishedAgo": "14 днів тому",
    "publishedDate": "04.03.2024",
    "size": "50 KB",
    "views": "4 029 переглядів",
    "status": "Архівовано",
    "statusCode": "Archived"
  }
]

Інтеграція з RequestLocalizationMiddleware

Замість ручного передавання lang параметра — Humanizer може читати CultureInfo.CurrentCulture, яку вже встановив UseRequestLocalization:

// Замість: post.PublishedAt.Humanize(culture: culture)
// Після того як middleware встановив Thread.CurrentCulture:
post.PublishedAt.Humanize()
// Humanizer автоматично використовує CultureInfo.CurrentCulture

Це ключова перевага: якщо в застосунку вже налаштована локалізація через UseRequestLocalization — Humanizer підхоплює культуру автоматично, без додаткового коду.


Частина 7. Огляд всіх можливостей (шпаргалка)

МетодПриклад входуПриклад виходу
DateTime.Humanize()DateTime.UtcNow - 2h"2 hours ago" / "2 години тому"
TimeSpan.Humanize()TimeSpan.FromMinutes(95)"1 hour, 35 minutes"
int.ToWords()42"forty-two" / "сорок два"
int.ToOrdinalWords()3"third" / "третій"
int.Ordinalize()21"21st"
string.Humanize()"PascalCase""Pascal case"
string.Pascalize()"some string""SomeString"
string.Camelize()"Some String""someString"
string.Underscore()"SomeProperty""some_property"
string.Kebaberize()"SomeProperty""some-property"
string.Titleize()"some title""Some Title"
string.Truncate()"Long string...""Long stri…"
string.Pluralize()"child""children"
string.Singularize()"people""person"
string.ToQuantity()"file", 5"5 files"
Enum.Humanize()OrderStatus.InProgress"In progress"
"str".DehumanizeTo<T>()"In progress"OrderStatus.InProgress
ByteSize.FromBytes().Humanize()1_500_000"1.43 MB"
2.Hours()TimeSpan.FromHours(2)
7.Days()TimeSpan.FromDays(7)

Практичні завдання

Рівень 1 — Базовий

Завдання 1.1. Додайте до ShopApi endpoint GET /products/{id}/activity що повертає:

  • createdAgo — скільки часу тому створено товар (DateTime.Humanize())
  • priceRange — якщо ціна менше 100 — "cheap", від 100 до 1000 — "medium", більше — "premium" (через власну логіку, але з Humanize() для форматування)
  • sizeLabel — якщо є поле FileSizeBytes, відобразіть через ByteSize.Humanize()

Завдання 1.2. Реалізуйте endpoint GET /datetime-demo що повертає масив з 10 дат (від 5 секунд тому до 1 року тому), для кожної — Humanize() англійською, українською та польською мовами одночасно. Приклад виводу:

{
  "original": "2024-03-01T12:00:00Z",
  "en": "17 days ago",
  "uk": "17 днів тому",
  "pl": "17 dni temu"
}

Рівень 2 — Логіка

Завдання 2.1. Реалізуйте ActivityFeedService що повертає стрічку подій (список ActivityEvent: тип події через enum, дата, опис). Для кожної події: час через Humanize(), тип через Enum.Humanize() з [Description] атрибутами трьома мовами (через власні описи), розмір через ByteSize якщо застосовно.

Завдання 2.2. Напишіть HumanizedValidationResult — структуру що отримує колекцію помилок і рахує їх: "1 error found" або "3 errors found" через "error".ToQuantity(count). Для украномовного варіанту: "знайдено 3 помилки" (тут Pluralize() не підтримує uk — реалізуйте власну логіку відмінювання через словник форм слова).

Рівень 3 — Архітектура

Завдання 3.1. Реалізуйте власний IStringLocalizer-адаптер для Humanizer: клас HumanizerLocalizer що реалізує IStringLocalizer і для певних ключів делегує до Humanizer ("TimeAgo_{ticks}"new DateTime(ticks).Humanize()). Зареєструйте його у DI поряд зі стандартним файловим локалайзером так, щоб можна було декорувати запити обома.


Підсумок

Humanizer — це бібліотека, яку встановлюєш один раз і використовуєш скрізь. Вона вирішує задачі, які формально прості, але вимагають окремого коду без неї: відмінювання, відносний час, кейс-конвертації, розмір файлів.

Відносний час

DateTime.Humanize() та TimeSpan.Humanize() — найпопулярніша функція. Підтримує 67 мов, включно з правильним відмінюванням в українській та польській.

Числа та слова

ToWords(), ToOrdinalWords(), Ordinalize(), ToQuantity() — вирішують задачу відмінювань без власних умов. Pluralize() / Singularize() — для англійської.

Рядкові перетворення

Pascalize(), Camelize(), Underscore(), Kebaberize(), Humanize() — повний набір для конвертації між стилями іменування.

ByteSize та Enum

ByteSize.Humanize() для розмірів файлів, Enum.Humanize() з [Description] для читабельних статусів — щоденний інструмент у будь-якому API.

Разом з IStringLocalizer для бізнес-текстів та CultureInfo для форматування чисел і дат — Humanizer закриває третій тип i18n-задач: читабельне представлення типів .NET. Ці три компоненти разом утворюють повноцінний i18n-стек для ASP.NET Core.

Copyright © 2026