The uni.time package provides a robust, "smart" date-time parser designed to handle the messy, inconsistent date formats often found in real-world datasets. It eliminates the typical ETL step of writing format-specific regex cleaners before ingestion.
This is the reference for uni.time in client projects. For paths, subprocesses and argument parsing see UniScriptingTools.md.
- Format Autodetection: Automatically identifies dozens of common formats (ISO-8601, RFC-2822, US/European numeric, textual months, etc.) without requiring a format string.
- Ambiguity Resolution: Configurable handling of ambiguous numeric dates (e.g.,
01/02/2024as Jan 2nd vs Feb 1st), including strict modes that reject the other reading outright. - Thread-Safe Scoping: Use
withDMY,withMDYorwithDateOrderto set parsing preferences for specific blocks of code or threads. - Resilient Tokenization: Gracefully handles varied delimiters (hyphens, slashes, dots, spaces) and extraneous text like weekdays or timezone abbreviations.
- No date-library dependency: the date type is uni's own, so the parser carries no
java.timecoupling.
parseDate(String) returns a UniDateTime, which is also available under the alias DateTime. Annotate with DateTime and your code follows uni's choice automatically.
#!/usr/bin/env -S scala-cli shebang -Wunused:imports -Wunused:locals -deprecation
//> using dep org.vastblue:uni_3:0.24.3
import uni.time.*
// Standard ISO
val d1: DateTime = parseDate("2024-03-14T15:30:00")
// Common US format
val d2 = parseDate("03/14/2024 3:30 PM")
// Textual month
val d3 = parseDate("14-Mar-2024")
// RFC-style with weekday and timezone
val d4 = parseDate("Thu, 14 Mar 2024 15:30:00 -0700")UniDateTime is a plain case class of integer fields (year, month, day, hour, minute, second, nano, plus an optional offsetMinutes). Constructing one goes through UniDateTime.of, which validates — February 30th yields BadDate rather than throwing.
Interop with java.time is implicit. A UniDateTime converts to a LocalDateTime wherever one is expected, and back again, so passing a parsed date to a java.time API needs no ceremony:
#!/usr/bin/env -S scala-cli shebang -Wunused:imports -Wunused:locals -deprecation
//> using dep org.vastblue:uni_3:0.24.3
import uni.time.*
val ldt: java.time.LocalDateTime = parseDate("2024-03-14") // conversion applies
val back: DateTime = java.time.LocalDateTime.now() // and in reverse
println(s"$ldt ${back.isValid}") // 2024-03-14T00:00 trueApplying the conversion emits a feature warning under -feature, never an error. To avoid it, prefer uni's own constructors — DateTime.of(y, m, d) rather than java.time.LocalDateTime.of(y, m, d, 0, 0, 0).
The alias carries a matching object, so the familiar static calls work:
#!/usr/bin/env -S scala-cli shebang -Wunused:imports -Wunused:locals -deprecation
//> using dep org.vastblue:uni_3:0.24.3
import uni.time.*
val instant = java.time.Instant.parse("2024-03-14T15:30:00Z")
val zone = java.time.ZoneId.of("UTC")
DateTime.now() // current moment
DateTime.of(2024, 3, 14) // time fields default to zero
DateTime.of(2024, 3, 14, 15, 30, 0)
DateTime.ofInstant(instant, zone)
DateTime.parse("Aug 18 22:29:47 2018") // uni's lenient parser, not ISO-only
println(s"${DateTime.of(2024, 3, 14)} ${DateTime.of(2024, 3, 14, 15, 30, 0)} ${DateTime.ofInstant(instant, zone)} ${DateTime.parse("Aug 18 22:29:47 2018")}")
// 2024-03-14T00:00 2024-03-14T15:30 2024-03-14T15:30 2018-08-18T22:29:47DateTime.of yields BadDate where java.time.LocalDateTime.of would throw. TimeUtils.now is also available unqualified as now (no parens — that spelling is historical).
parseDate is total. Unparseable input yields BadDate; empty input yields EmptyDate. That is what makes it usable across a CSV column without wrapping every cell in a Try.
#!/usr/bin/env -S scala-cli shebang -Wunused:imports -Wunused:locals -deprecation
//> using dep org.vastblue:uni_3:0.24.3
import uni.time.*
def handleBadRow(): Unit = println("bad row")
def use(d: DateTime): Unit = println(s"using $d")
for cell <- Seq("2024-03-14", "not a date") do
val d = parseDate(cell)
if d == BadDate then handleBadRow() else use(d)
// or
if !d.isValid then handleBadRow()Three properties worth knowing:
They are dates that cannot exist. Both sentinels carry day 0, which occurs in no month. Since of validates, no parsed date can ever equal a sentinel — so a data file containing a real 1900-01-02 produces an ordinary date, not a false parse failure. (It used to be exactly that value, and 1900-01-02 is a common placeholder in financial data.)
They print as markers. BadDate.toString is <BadDate>, not a date-shaped string. A sentinel in a log line or a printed column is visible rather than reading as a genuine moment from 1900.
But the pattern formatters still yield digits. fmt, ymd and toString(fmt) feed filenames, CSV keys and SQL binds, so they keep producing digits — BadDate.fmt("yyyyMMdd") is 19000100. Day zero makes that self-evidently not a date, but if you are building such a string, guard with isValid first.
Arithmetic absorbs them, the way Double.NaN does: BadDate.plusDays(1) is BadDate, and BadDate.toEpochDay is Long.MinValue.
#!/usr/bin/env -S scala-cli shebang -Wunused:imports -Wunused:locals -deprecation
//> using dep org.vastblue:uni_3:0.24.3
import uni.time.*
val d = parseDate("2024-03-14 15:30:45")
println(d.ymd) // 2024-03-14
println(d.ymdhms) // 2024-03-14 15:30:45
println(d.fmt("yyyyMMdd")) // 20240314
println(d.toString("MMM d, yyyy")) // Mar 14, 2024
println(d.toString) // 2024-03-14T15:30:45 (ISO, seconds omitted when zero)Pattern letters: y year, M month, d day, H hour (0-23), h hour (1-12), m minute, s second, S fraction, a AM/PM, E day of week. Repeat count sets width as in Java (M=3, MM=03, MMM=Mar, MMMM=March). Text in single quotes is literal.
Two deliberate differences from DateTimeFormatter.ofPattern:
- Month and weekday names are always English.
ofPatternfollows the JVM's default FORMAT locale, which makes output machine-dependent. - Capital
Yis rejected with an error rather than accepted. It is the locale-dependent week-based year, which differs fromyonly for a few days each December — so a typo silently renders a date a year out. Use lowercasey.
The usual shifts are available and return a DateTime, so results compose:
#!/usr/bin/env -S scala-cli shebang -Wunused:imports -Wunused:locals -deprecation
//> using dep org.vastblue:uni_3:0.24.3
import uni.time.*
val d = parseDate("2024-03-14 15:30:45") // a Thursday
val inst = java.time.Instant.parse("2024-03-14T15:30:00Z")
d.plusDays(7); d.minusWeeks(2); d.plusMonths(1); d.plusYears(1)
d.withDayOfMonth(1); d.withHour(0)
d.atStartOfDay(); d.lastDayOfMonth
d.withDayOfWeek(java.time.DayOfWeek.FRIDAY) // the NEXT such day
d.dayOfWeekNum // 1 = Monday .. 7 = Sunday
d.dayOfWeekName // "Thu" (three-letter, always English)
d.dayOfWeekFull // "Thursday"
d.toEpochDay // days since 1970-01-01
DateTime.ofInstant(inst)
UniDateTime.ofEpochDay(19800)
println(s"${d.plusDays(7).ymd} ${d.lastDayOfMonth.ymd} ${d.withDayOfWeek(java.time.DayOfWeek.FRIDAY).ymd} ${d.dayOfWeekNum} ${d.dayOfWeekName} ${d.dayOfWeekFull} ${d.toEpochDay} ${UniDateTime.ofEpochDay(19800).ymd}")
// 2024-03-21 2024-03-31 2024-03-15 4 Thu Thursday 19796 2024-03-18Month arithmetic clamps to the shorter month, as java.time does: January 31st plus one month is February 28th or 29th. An impossible result yields BadDate rather than throwing.
Comparison uses local fields: <, <=, >, >=, isBefore, isAfter, and an Ordering[UniDateTime] so Seq[DateTime].sorted works.
dayOfWeekName is the form most code actually wants, and replaces a hand-rolled
getDisplayName(TextStyle.SHORT, Locale.ENGLISH). The explicit Locale in that idiom is
load-bearing: without it getDisplayName follows the JVM default and the same script yields
lun. on a French machine. These are English by construction, so there is nothing to get
wrong — and nothing to make the output depend on the host.
Interval helpers take dates in either representation: daysBetween, secondsBetween, minutesBetween, hoursBetween, elapsedDays, getDuration, endOfMonth.
When a date like 02/11/2009 is encountered, it could be February 11th (MDY) or November 2nd (DMY). By default, uni.time prefers MDY.
You can override this behavior using scoped configuration blocks:
#!/usr/bin/env -S scala-cli shebang -Wunused:imports -Wunused:locals -deprecation
//> using dep org.vastblue:uni_3:0.24.3
import uni.time.*
// Default (MDY)
parseDate("02/11/2009") // => 2009-02-11
// Explicitly Day-Month-Year
withDMY {
parseDate("02/11/2009") // => 2009-11-02
}
// Scopes can be nested
withDMY {
// DMY logic here...
withMDY {
// MDY logic here...
}
}Because these scopes use scala.util.DynamicVariable, they are thread-safe. You can have multiple threads parsing different datasets with different conventions simultaneously without interference.
withDMY/withMDY decide only the genuinely ambiguous case. An unambiguous date still overrides them — under withMDY, 04/12/1992 reads as April 12 while 13/12/1992 reads as December 13. For an internally consistent European column that yields a mix, with the wrong reading on every row whose day is 12 or less.
withDateOrder holds to one convention instead:
#!/usr/bin/env -S scala-cli shebang -Wunused:imports -Wunused:locals -deprecation
//> using dep org.vastblue:uni_3:0.24.3
import uni.time.*
withDateOrder(DateOrder.DayFirst) {
println(parseDate("04/12/1992").ymd) // 1992-12-04, day-first even though unambiguous
println(parseDate("12/04/1992").ymd) // 1992-04-12
}DateOrder.Auto— unambiguous input wins, ambiguous input uses the MDY/DMY preference. The default, so nothing has to be declared.DateOrder.MonthFirst— month first always; a date only readable day-first yieldsBadDate.DateOrder.DayFirst— day first always; a date only readable month-first yieldsBadDate.
The strict modes make a wrong-convention row a visible BadDate rather than a plausible wrong date, which is usually what you want when ingesting a file whose convention you know.
The date parser is integrated into uni.data.BigUtils.getMostSpecificType. This means when you load a CSV or a raw sequence of strings, dates are automatically promoted to DateTime objects while numbers are promoted to Big.
#!/usr/bin/env -S scala-cli shebang -Wunused:imports -Wunused:locals -deprecation
//> using dep org.vastblue:uni_3:0.24.3
import uni.data.* // exports BigUtils; importing both makes the names ambiguous
val raw = Seq("2024-03-14", "$1,234.56", "7.5K")
val typed = raw.map(getMostSpecificType)
// => Seq(UniDateTime(2024, 3, 14...), Big(1234.56), Big(7500))The CVD cell-value union is UniDateTime | Big | Option[Int] | String | Int, and toStr renders a date member as yyyy-MM-dd.
SmartParse classifies tokens and is the only parser — an earlier ChronoParse fallback was measured against the project's date corpus, rescued nothing SmartParse failed, and was removed. The two formats it alone handled (bare yyyyMMdd, and a leading time) are folded into normalization.
| Example String | Interpretation |
|---|---|
2024-03-14T15:30:00Z |
ISO-8601 |
03/14/2024 |
MDY |
14/03/2024 |
DMY (Autodetected by >12 rule) |
Mar 14, 2024 |
Month Name |
14 Mar 2024 |
Day Month Name |
Apr12-11 |
Squashed Month/Day |
20240314 |
Bare compact date |
2009/03/24 21:48:25.0 |
YMD with fractional seconds |
Fri Jan 10 2014 2:34:17 PM EST |
Full textual with AM/PM and TZ |
A stated UTC offset (-0700, or a trailing Z) is captured in offsetMinutes rather than discarded. It is not applied to the local fields, and toString omits it — UniDateTime records the offset, it does not shift by it. Named abbreviations like MST are deliberately not mapped, since CST alone is three different offsets and resolving them needs a timezone database.
== across the two date types is silently false. == takes Any, so no conversion applies and the compiler says nothing:
val d: DateTime = parseDate(s)
if d == java.time.LocalDateTime.of(1900, 1, 2, 3, 4, 5) then ... // always false
Keep both sides on DateTime. Comparing against BadDate/EmptyDate is safe because those are UniDateTime too. In tests, munit's assertEquals requires a Compare instance and so catches the mistake at compile time — plain == does not.
A java.time.LocalDateTime written in a generic position will not convert. A conversion applies to a value, never through a type constructor:
val ok: Seq[java.time.LocalDateTime] = lines.map(parseDate) // fine: inference pushes inward
val xs = lines.map(parseDate)
val no: Seq[java.time.LocalDateTime] = xs // error: Seq[UniDateTime]
The same applies to Option[LocalDateTime], an explicit Ordering[LocalDateTime], and a case d: LocalDateTime test. Each is a compile error rather than a silent difference; write DateTime so the annotation tracks the alias.
Mixing date types in one expression infers a union. If one branch yields a LocalDateTime and another a DateTime, Scala 3 infers LocalDateTime | UniDateTime, which satisfies neither position and which no conversion repairs. Keep a given expression on one type.
- No Exceptions:
parseDatereturnsBadDateinstead of throwing, making it safe for use in data pipelines.quikDateis the strict counterpart for compactyyyyMMdd— it throws on malformed input. - Normalization: Input strings are pre-normalized to remove Unicode punctuation and standardize whitespace before tokenization.
- Formatting goes through
uni.time.DateFormat, which works on the field values rather than ajava.timeobject. That is what decouples the date type fromjava.time.
rust/src/utime/ ports the date type and its formatter:
| Scala | Rust |
|---|---|
uni.time.UniDateTime |
utime::datetime::UniDateTime |
uni.time.DateFormat |
utime::format |
uni.time.SmartParse, parseDate |
utime::smartparse |
Path.lastModifiedTime, .lastMod*Ago |
upath::times |
Method names match the Scala spelling — plusDays, dayOfWeekName, lastModifiedTime —
not snake_case, so a script kept in both languages needs no mental translation. Internal
helpers stay snake_case, which means the case tells you whether a Scala counterpart exists.
See docs/PathIOReference.md for the full rule.
File timestamps are UTC on both sides, with no zone or offset parameter. That was the one
place the two languages could not have matched: std has no timezone database, so a
system-local lastModifiedTime was unreproducible. Defining it as UTC removed the problem
rather than working around it, and incidentally fixed a Scala inconsistency — epoch2DateTime
had always defaulted to UTC while lastModifiedTime used the system zone.
Same fields, same validation, same sentinels (day 0, <BadDate>), same epoch-day arithmetic —
and no date-library dependency, which was the point of decoupling the Scala type in the
first place. The alternative would have been a crate plus a translation layer from Java's
pattern letters to strftime codes.
SmartParse is ported as utime::smartparse -- parseDateSmart(s) reads a date string of
unknown format or answers BAD_DATE, exactly as in Scala, and classify / numericDateOrder
come with it. Two adaptations, both visible in the signatures rather than the behaviour:
- Configuration is a parameter. The Scala consults the dynamically-scoped
timeConfig; Rust has no dynamic scope, soparseDateSmartWith(s, TimeConfig { monthFirst, order })takes it explicitly andparseDateSmartfixes the default (monthFirst = true,order = Auto). - The fuzzy month scan has a defined order. A typo within edit distance 1 of two months
(
jum) resolves byMapiteration order in Scala, which is unspecified; the Rust scans January to December. The parity fixture pins only unambiguous typos, on purpose.
Pinned by test-data/smartparse-parity/ -- 539 recorded answers over the shared date corpus
plus one input per documented trap (the RFC 2822 layout, trailing -0700 offsets, compact
yyyyMMdd HHmm, AM/PM edge cases, 2-digit-year expansion, fuzzy month words), under all four
day/month-order configurations for the ambiguous cases. Regenerate with
sbt "Test/runMain uni.apps.SmartParseParityGen".
One difference in the arithmetic, and it matters for trust: the Scala delegates to java.time
for calendar math, while the Rust has nothing to delegate to and writes it out — Hinnant's
civil-calendar algorithms for epoch days, explicit month-end clamping, explicit carry for time
shifts. test-data/date-parity/ pins the two together across 4020 cases, which makes
java.time the transitive oracle for the hand-rolled version. It earned its keep immediately:
it caught the port returning 2000-03-01 for 2000-02-29, an off-by-one in the day-of-era
divisor that shows up only on the last day of a 400-year era.
Regenerate the fixture with sbt "runMain uni.apps.DateParityGen", and only when the reference
is meant to move — both uni.time.DateParitySuite and rust/tests/date_parity.rs check
against it, so a change to either language fails until the other agrees.
The crate is named uni (rust/Cargo.toml), mirroring the Scala library; utime and
upath are modules within it.