HumanTime.Net 0.1.0

dotnet add package HumanTime.Net --version 0.1.0
                    
NuGet\Install-Package HumanTime.Net -Version 0.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="HumanTime.Net" Version="0.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="HumanTime.Net" Version="0.1.0" />
                    
Directory.Packages.props
<PackageReference Include="HumanTime.Net" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add HumanTime.Net --version 0.1.0
                    
#r "nuget: HumanTime.Net, 0.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package HumanTime.Net@0.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=HumanTime.Net&version=0.1.0
                    
Install as a Cake Addin
#tool nuget:?package=HumanTime.Net&version=0.1.0
                    
Install as a Cake Tool

HumanTime.NET

Human-friendly duration parsing and formatting for .NET: "1h30m" to TimeSpan and back. One permissive grammar covers Go time.ParseDuration syntax, plain words, TimeSpan colon syntax, and ISO-8601. Zero external dependencies.

The headline: this finally works in appsettings.json.

{
  "Client": {
    "Name": "api",
    "Timeout": "30s",
    "RetryDelay": "1h30m",
    "Heartbeat": "00:05:00"
  }
}

Every major ecosystem solved this years ago. Go's time.ParseDuration is standard library, and its 30s / 5m syntax leaked into the entire cloud-native configuration world: Kubernetes, Prometheus, Docker, Caddy. Rust's humantime has over 400 million downloads. The npm ms family serves billions of downloads a month. Python's durationpy does tens of millions a month. In .NET, TimeSpan.Parse reads "00:01:30" but cannot read "90s", Humanizer formats durations out but does not parse them in, and the one dedicated parsing attempt (TimeSpanParserUtil) has been unmaintained since 2020. This library is the missing piece: strict about ambiguity, loud about errors, and tested against Go's own ParseDuration vectors as the oracle.

What it gives you:

  • Duration.Parse / Duration.TryParse: one call accepts "300ms", "2h45m", "-1.5h", "2 days 4 hours", "1 hour and 30 minutes", "1:30:00", "1.02:03:04", "PT1H30M", "P2W"
  • Duration.Format in five styles: Compact ("1h 30m"), Go ("1h30m0s", exact time.Duration.String() parity), Verbose ("1 hour 30 minutes"), Colon (TimeSpan constant format), Iso8601 ("PT1H30M")
  • Every style round-trips exactly: Parse(Format(value, style)) == value for all five styles, all the way to TimeSpan.MinValue and TimeSpan.MaxValue
  • HumanTimeSpanConverter: a TypeConverter that makes "30s" bind to TimeSpan through Microsoft.Extensions.Configuration and IOptions
  • HumanTimeJsonConverter: a System.Text.Json converter for "timeout": "30s" in JSON payloads
  • Span-based scanner, zero bytes allocated on the successful parse path, invariant culture everywhere, millions of mixed-syntax parses per second single-threaded
  • Fail-loud errors: a FormatException-derived HumanTimeParseException with the failure position, OverflowException past the TimeSpan range, and a TryParse that never throws (fuzz-tested, including lone surrogates)

Install

dotnet add package HumanTime.Net

Quickstart

using HumanTime;

TimeSpan t1 = Duration.Parse("1h30m");          // 01:30:00
TimeSpan t2 = Duration.Parse("90 seconds");     // 00:01:30
TimeSpan t3 = Duration.Parse("2 days 4 hours"); // 2.04:00:00
TimeSpan t4 = Duration.Parse("PT1H30M");        // 01:30:00 (ISO-8601)
TimeSpan t5 = Duration.Parse("1:30:00");        // 01:30:00 (TimeSpan colon syntax)

bool ok = Duration.TryParse("just vibes", out TimeSpan value); // false, never throws
TimeSpan value = TimeSpan.FromMinutes(90);

Duration.Format(value);                          // "1h 30m"
Duration.Format(value, DurationStyle.Go);        // "1h30m0s"
Duration.Format(value, DurationStyle.Verbose);   // "1 hour 30 minutes"
Duration.Format(value, DurationStyle.Colon);     // "01:30:00"
Duration.Format(value, DurationStyle.Iso8601);   // "PT1H30M"

Configuration binding: "Timeout": "30s"

Register the converter once at startup, then bind as usual:

using HumanTime;
using Microsoft.Extensions.Configuration;

HumanTimeSpanConverter.Register();

var configuration = new ConfigurationBuilder()
    .AddJsonFile("appsettings.json")
    .Build();

var options = configuration.GetSection("Client").Get<ClientOptions>();
// options.Timeout    == 30 seconds
// options.RetryDelay == 1 hour 30 minutes
// options.Heartbeat  == 5 minutes ("00:05:00" still works: the grammar includes colon syntax)

public sealed class ClientOptions
{
    public string Name { get; set; } = "";

    public TimeSpan Timeout { get; set; }

    public TimeSpan RetryDelay { get; set; }

    public TimeSpan Heartbeat { get; set; }
}

The same registration makes services.AddOptions<ClientOptions>().Bind(section) and IOptions<ClientOptions> work. This exact flow (real ConfigurationBuilder, real JSON file, options binding through dependency injection) is verified in the test suite, including the negative case: before Register() the binder rejects "30s", after Register() it binds.

How it works, honestly:

  • Register() calls TypeDescriptor.AddAttributes(typeof(TimeSpan), ...) to make this converter the process-wide TypeConverter for TimeSpan. The reflection-based configuration binder (what Get<T>, Bind, and IOptions use by default) resolves converters through TypeDescriptor, so it picks this up. This is the tested, working path.
  • The source-generated configuration binder (EnableConfigurationBindingGenerator=true, on by default when publishing with Native AOT) emits a direct TimeSpan.Parse call and bypasses TypeConverter entirely. Under that binder, "30s" in configuration will not bind; keep colon syntax in configuration, or parse explicitly with Duration.Parse.
  • Registration is process-wide state, and it reaches every TypeConverter consumer in the process, not just the configuration binder. Two measured consequences, stated plainly:
    • Output direction: ConvertTo(string) emits the invariant colon ("c") format ("01:30:00"), so anything that formats a TimeSpan through the registered converter keeps producing strings that TimeSpan.Parse and unregistered processes can read. "1h 30m" output remains available explicitly via Duration.Format.
    • Input direction: registration replaces the culture-aware default TimeSpan converter, so culture-specific colon strings stop converting (de-DE "0:00:01,5" no longer parses through the converter). The tradeoff is deliberate: the invariant grammar becomes the single contract.
  • HumanTimeSpanConverter.Unregister() fully undoes Register() and restores the default converter, verified in the test suite. Standard invariant TimeSpan strings keep working while registered because the grammar accepts the invariant colon format.

JSON payloads

using System.Text.Json;
using HumanTime;

var options = new JsonSerializerOptions { Converters = { new HumanTimeJsonConverter() } };

var job = JsonSerializer.Deserialize<JobOptions>("""{"Timeout":"45s","Interval":"5m"}""", options)!;
string json = JsonSerializer.Serialize(job, options); // {"Timeout":"45s","Interval":"5m"}

public sealed record JobOptions(TimeSpan Timeout, TimeSpan Interval);

Reading accepts the full grammar. Writing defaults to Compact; pass a style to the constructor (new HumanTimeJsonConverter(DurationStyle.Go)) to write "1h30m0s" instead. Attribute form works too: [property: JsonConverter(typeof(HumanTimeJsonConverter))].

Grammar reference

Syntax family Examples Notes
Go 300ms, -1.5h, 2h45m, 1h30m10s500ms, 12µs, 100ns Full time.ParseDuration grammar; Go's own test vectors pass verbatim
Words 2 days 4 hours, 90 seconds, 1.5 hours, 1 hour, 30 minutes, 1 hour and 30 minutes Singular and plural, abbreviations, commas and and tolerated between components
Colon 1:30, 1:30:00, 1.02:03:04, 0:00:01.5 Exactly what invariant TimeSpan.Parse accepts
ISO-8601 PT1H30M, P1DT2H, P2W, PT0.5S, pt1h30m Case-insensitive; calendar Y/M components rejected
Zero 0 The only bare number that parses, matching Go

Units: ns, nanosecond(s); us, µs, μs, microsecond(s); ms, millisecond(s); s, sec(s), second(s); m, min(s), minute(s); h, hr(s), hour(s); d, day(s); w, wk(s), week(s). Components may repeat and appear in any order (10.5s4m is valid, as in Go). Mixing families within one unit sequence is fine (1h 30 minutes); colon and ISO forms stand alone.

Case policy: units match case-insensitively (1H, 10MS, 500NS, 1MIN all parse) with one deliberate exception: the single-letter unit M in uppercase is rejected (1M, 1H30M, 30M all throw), because nginx and systemd time syntax read a bare M as months, and silently diverging from that dialect is an outage waiting to happen. The error points at m for minutes. MS, MIN, and MINUTE(S) are unambiguous and stay accepted, and ISO-8601 PT1H30M is unaffected because position makes its M mean minutes.

Divergences from Go, all deliberate: d, day(s), w, wk(s), week(s) exist here (Go stops at hours); unit matching is case-insensitive except the uppercase-M rejection above (Go is lowercase-only); word, colon, and ISO syntaxes are accepted alongside Go's; and durations beyond Go's int64-nanosecond range parse when they fit in TimeSpan (3000000h overflows Go's ParseDuration but parses here).

Format styles

Style TimeSpan.FromMinutes(90) TimeSpan.FromTicks(15) Zero
Compact (default) 1h 30m 1us 500ns 0s
Go 1h30m0s 1.5µs 0s
Verbose 1 hour 30 minutes 1 microsecond 500 nanoseconds 0 seconds
Colon 01:30:00 00:00:00.0000015 00:00:00
Iso8601 PT1H30M PT0.0000015S PT0S

Round-trip guarantee: for every style, Duration.Parse(Duration.Format(value, style)) returns exactly value, and formatting is stable across a parse cycle. This holds for all five styles across the entire TimeSpan range, including TimeSpan.MinValue and TimeSpan.MaxValue, and is enforced by property tests over a deterministic corpus. Verbose is exact to the tick, not an approximation.

The ambiguity decisions, out loud

  • "1:30" is 1 hour 30 minutes (hh:mm), not 1 minute 30 seconds. This matches TimeSpan.Parse("1:30"). Write 90s or 0:01:30 for the other reading.
  • Two colon-lane foot-guns, verified and documented rather than papered over: "0:30" is 30 minutes, even though timer-culture users often mean 30 seconds (write "30s"); and "1.5:00" is 1 day 5 hours (d.h:mm), not one and a half hours (write "1.5h").
  • A bare uppercase M never means minutes: "1M" and "1H30M" are rejected with a pointer to m, because nginx and systemd read M as months. See the case policy under the grammar reference.
  • Negative ISO-8601 output puts the sign before the P (-PT1H30M), and only there on input too: "PT-1H" is rejected, like every other interior sign.
  • Bare numbers are rejected: "30" throws with a message naming the ambiguity (seconds? milliseconds?) and suggesting "30s". The single exception is "0", which parses to zero. This is exactly Go's rule.
  • The decimal separator is the period, invariant, everywhere. "1,5h" is rejected in every culture (the comma is a component separator: "1h, 30m"). Go accepts only the period; so does this library.
  • ISO-8601 Y (years) and date-position M (months) are rejected loudly: months and years vary in length, so they are not fixed durations, and pretending otherwise corrupts data. P1M fails with a message pointing at PT1M for minutes. Weeks (P2W) are supported but cannot mix with other ISO components, per the standard.
  • A leading minus applies to the whole expression (-1h30m is minus 90 minutes), as in Go. Interior signs are rejected.
  • TimeSpan ticks are 100ns, so nanosecond inputs round to the nearest tick, ties away from zero: "150ns" is 2 ticks, "10ns" is zero.
  • Colon syntax follows invariant TimeSpan.Parse exactly, including component ranges: "25:00" is invalid (hours above 23 need a day part), while "25h" is perfectly fine.
  • Values beyond the TimeSpan range throw OverflowException (TryParse returns false). One nuance: an out-of-range colon string like "10675200.00:00:00" surfaces as a parse error, because TimeSpan.TryParse does not distinguish the two.

Errors

Duration.Parse throws HumanTimeParseException (derived from FormatException) with the zero-based Position of the failure and a message that names the problem: the unknown unit, the bare number, the calendar unit, the dangling separator. Duration.TryParse never throws, on anything; the suite fuzzes it with 1,000 deterministic hostile inputs including lone surrogates, NUL characters, and 10,000-digit numbers.

Performance

The parser is a single-pass span scanner: no regex, no intermediate strings, and zero bytes allocated on the successful parse path. The zero-allocation figure was measured with GC.GetAllocatedBytesForCurrentThread across all four syntax families including colon, and the suite asserts exactly zero bytes across 1,000 Go-syntax span parses. The suite's performance assertions are deliberate floors, not the measured numbers: throughput must exceed 100,000 parses per second on a mixed corpus, and the equal-character scaling ratio must stay under 6.0. Measured values sit far above the floors: 1.2 to 3.9 million parses per second across review machines, with a scaling ratio of 0.94 against 1.0 for perfectly linear.

Limitations and roadmap

  • Verbose output is English-only. Localization of word formatting is on the roadmap; parsing already ignores culture by design.
  • No approximate humanization ("about 2 hours"). Formatting is exact by design; a lossy humanized style may come later as an explicitly separate mode.
  • Calendar units (months, years) are intentionally out of scope, in both directions.
  • The source-generated configuration binder bypasses TypeConverter (see the configuration section). If the binder ever exposes an extension point, a hook will follow.
  • A NodaTime Duration adapter package is under consideration.

License

MIT. See LICENSE.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net8.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.1.0 92 8/4/2026