HumanTime.Net
0.1.0
dotnet add package HumanTime.Net --version 0.1.0
NuGet\Install-Package HumanTime.Net -Version 0.1.0
<PackageReference Include="HumanTime.Net" Version="0.1.0" />
<PackageVersion Include="HumanTime.Net" Version="0.1.0" />
<PackageReference Include="HumanTime.Net" />
paket add HumanTime.Net --version 0.1.0
#r "nuget: HumanTime.Net, 0.1.0"
#:package HumanTime.Net@0.1.0
#addin nuget:?package=HumanTime.Net&version=0.1.0
#tool nuget:?package=HumanTime.Net&version=0.1.0
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.Formatin five styles:Compact("1h 30m"),Go("1h30m0s", exacttime.Duration.String()parity),Verbose("1 hour 30 minutes"),Colon(TimeSpanconstant format),Iso8601("PT1H30M")- Every style round-trips exactly:
Parse(Format(value, style)) == valuefor all five styles, all the way toTimeSpan.MinValueandTimeSpan.MaxValue HumanTimeSpanConverter: aTypeConverterthat makes"30s"bind toTimeSpanthroughMicrosoft.Extensions.ConfigurationandIOptionsHumanTimeJsonConverter: 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-derivedHumanTimeParseExceptionwith the failure position,OverflowExceptionpast theTimeSpanrange, and aTryParsethat 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()callsTypeDescriptor.AddAttributes(typeof(TimeSpan), ...)to make this converter the process-wideTypeConverterforTimeSpan. The reflection-based configuration binder (whatGet<T>,Bind, andIOptionsuse by default) resolves converters throughTypeDescriptor, 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 directTimeSpan.Parsecall and bypassesTypeConverterentirely. Under that binder,"30s"in configuration will not bind; keep colon syntax in configuration, or parse explicitly withDuration.Parse. - Registration is process-wide state, and it reaches every
TypeConverterconsumer 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 aTimeSpanthrough the registered converter keeps producing strings thatTimeSpan.Parseand unregistered processes can read."1h 30m"output remains available explicitly viaDuration.Format. - Input direction: registration replaces the culture-aware default
TimeSpanconverter, 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.
- Output direction:
HumanTimeSpanConverter.Unregister()fully undoesRegister()and restores the default converter, verified in the test suite. Standard invariantTimeSpanstrings 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 matchesTimeSpan.Parse("1:30"). Write90sor0:01:30for 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
Mnever means minutes:"1M"and"1H30M"are rejected with a pointer tom, because nginx and systemd readMas 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-positionM(months) are rejected loudly: months and years vary in length, so they are not fixed durations, and pretending otherwise corrupts data.P1Mfails with a message pointing atPT1Mfor minutes. Weeks (P2W) are supported but cannot mix with other ISO components, per the standard. - A leading minus applies to the whole expression (
-1h30mis minus 90 minutes), as in Go. Interior signs are rejected. TimeSpanticks 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.Parseexactly, including component ranges:"25:00"is invalid (hours above 23 need a day part), while"25h"is perfectly fine. - Values beyond the
TimeSpanrange throwOverflowException(TryParsereturns false). One nuance: an out-of-range colon string like"10675200.00:00:00"surfaces as a parse error, becauseTimeSpan.TryParsedoes 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
Verboseoutput 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
Durationadapter package is under consideration.
License
MIT. See LICENSE.
| Product | Versions 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. |
-
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 |