A Java port of the TypeScript ulid package, v3.0.2.
Universally-unique, lexicographically-sortable identifiers.
The goal of this port is behavioural equivalence, not idiomatic rewriting: every function, class, constant, error code, error message, edge case and side effect has a one-to-one Java counterpart.
mvn test # runs the migrated suite + the differential suite
mvn package # produces target/ulid-java-3.0.2.jar (executable CLI)Requires JDK 17+.
import io.github.ulid.ULID;
import io.github.ulid.ULIDFactory;
ULID.ulid(); // "01HNZXD07M5CEN5XA66EMZSRZW"
ULID.encodeTime(1469918176385L); // "01ARYZ6S41"
ULID.decodeTime("01ARYZ6S41TSV4RRFFQ69G5FAV"); // 1469918176385L
ULID.isValid("01HNZX8JGFACFA36RBXDHEQN6E"); // true
ULIDFactory factory = ULID.monotonicFactory();
factory.create(); // monotonically increasing
factory.create(1469918176385L);
ULID.ulidToUUID("01ARYZ6S41TSV4RRFFQ69G5FAV"); // "01563DF3-6481-D676-4C61-EFB99302BD5B"
ULID.uuidToULID("0195C9A4-2E32-C014-5F4F-A7CEF5BE83D5");
ULID.fixULIDBase32("oLARYZ6-S41TSV4RRF-FQ69G5FAV");
ULID.incrementBase32("A109C"); // "A109D"CLI:
java -jar target/ulid-java-3.0.2.jar --count 5| TypeScript | Java | Notes |
|---|---|---|
source/index.ts |
ULID.java |
Public facade / re-exports |
source/ulid.ts |
UlidCore.java |
Named UlidCore because case-insensitive filesystems cannot hold both ULID.java and Ulid.java |
source/crockford.ts |
Crockford.java |
|
source/uuid.ts |
UuidConvert.java |
|
source/utils.ts |
Utils.java |
Plus JS-semantics helpers |
source/constants.ts |
Constants.java |
|
source/error.ts |
ULIDError.java, ULIDErrorCode.java |
|
source/types.ts |
PRNG.java, ULIDFactory.java |
ULID/UUID string aliases become String |
source/cli.ts |
Cli.java |
|
source/stub.ts |
(omitted) | export default undefined — a bundler placeholder with no runtime behaviour |
| TypeScript | Java | Rationale |
|---|---|---|
number (timestamps) |
double primary, long overloads |
double keeps the NaN / non-integer validation branches reachable |
string |
String |
|
Uint8Array |
byte[] |
Read back via & 0xFF for unsigned semantics |
PRNG = () => number |
@FunctionalInterface PRNG |
|
ULIDFactory = (seedTime?) => ULID |
@FunctionalInterface ULIDFactory |
null models undefined |
string enum ULIDErrorCode |
enum with value() |
toString() returns the literal so message interpolation matches |
class ULIDError extends Error |
extends RuntimeException |
JS errors are unchecked |
These are the subtle points where a naive port silently diverges.
-
incrementBase32loop boundary. The TS loop iswhile (!done && index-- >= 0). The post-decrement runs before the body, so the body readsoutput[index - 1]. Whenindexreaches0the comparison still passes whileindexbecomes-1, makingoutput[-1]undefined;ENCODING.indexOf(undefined)is-1. A saturated input such as"ZZZ"therefore exits via theB32_ENC_INVALID("Incorrectly encoded string") throw — the trailing"Failed incrementing string"throw is unreachable. Both throws are ported; the reachable one is asserted by the tests. -
Falsy
seedTime.!seedTime || isNaN(seedTime)means0,-0,NaN,nullandundefinedall fall back toDate.now(). Passing0does not encode epoch zero. -
ECMA-262 number stringification. Error messages interpolate the raw number. Java's
Double.toStringswitches to exponential notation at1e7, JavaScript at1e21, so1469918176385.5would render as1.4699181763855E12.Utils.jsNumberToStringimplements the ECMA-262 §6.1.6.1.20 algorithm, including computing the genuinely shortest round-tripping decimal —Double.toStringis contractually required to emit at least one fractional digit and so rendersDouble.MIN_VALUEas4.9E-324rather than JavaScript's5e-324. -
Locale-independent case folding. All
toUpperCase()calls useLocale.ROOT. The JVM default locale would corrupti/Iunder a Turkish locale, silently breaking Crockford decoding. -
32-bit bitwise semantics. JS coerces to int32 for
|,<<,>>>. Java'sintand>>>reproduce this exactly;Uint8Arrayreads use& 0xFF. -
String.charAtvs bracket access.charAt(oob)returns"";str[oob]returnsundefined. Modelled byUtils.jsCharAtandUtils.jsCharacterAt. -
parseInt(x, 10)consumes only leading digits, so--count 2abcyields2. -
detectPRNG. The TS original probeswindow/self/globalforcrypto.getRandomValues, falling back to Node'scrypto.randomBytes(1).readUInt8() / 256. The JVM has a single cryptographic source, so this returns the exact numeric equivalent of that fallback:SecureRandomunsigned byte / 256, yielding[0, 0.99609375].
1. Migrated test suite — all 21 original vitest cases, same inputs, assertions and
expected outputs. The two describe blocks that share a stubbed factory across
sequential it calls are modelled as ordered @Nested classes with a per-class
lifecycle, preserving the mutation order the originals depend on.
| Original spec | Java test | Cases |
|---|---|---|
test/node/ulid.spec.ts |
UlidTest |
14 |
test/node/crockford.spec.ts |
CrockfordTest |
5 |
test/node/uuid.spec.ts |
UuidTest |
2 |
A further 56 tests (PublicApiTest, InternalsTest, CliTest, UnreachabilityTest)
cover the surface the original suite never reaches — isValid, every error code, the
CLI argument parser and the JS-semantics helpers — bringing the suite to 77 tests
at 99.0% line / 98.4% branch / 100% method coverage.
Three statements remain uncovered by design: the trailing "Failed incrementing string"
throw in Crockford, its loop-exhaustion break, and the Unexpected throw in
UuidConvert. Each mirrors a statement that is equally unreachable in the TypeScript
original and equally uncovered by the original's own suite. UnreachabilityTest
measures the claim rather than asserting it: it drives the enclosing functions over
their whole reachable input space and fails if a dead branch ever fires.
2. Differential suite (DifferentialTest) — tools/generate.mjs evaluates a
deterministic corpus of 3,075 cases against the real built npm package and records
the reference results into src/test/resources/differential/cases.tsv; the Java port
replays the identical corpus and compares every result, including exact error-message
text, byte-for-byte. Both sides share an identical MINSTD PRNG so random paths are
reproducible.
The shipped corpus is a sample of a larger 18,485-case sweep; the sample was confirmed to
reproduce the larger run's JaCoCo line and branch coverage exactly, class by class. Raise
the loop bounds in tools/generate.mjs to regenerate the full sweep.
Coverage includes boundary timestamps (0, TIME_MAX ± 1), NaN/±Infinity,
non-integral and subnormal doubles, every encodeTime length, malformed/invalid ULIDs
and UUIDs, deep incrementBase32 carry chains, monotonic saturation and rollover,
and UUID↔ULID round-trips.
To regenerate:
node tools/generate.mjs && mvn test
# if the reference repository lives elsewhere:
ULID_REFERENCE_DIST=/path/to/ulid_javascript/dist/node/index.js node tools/generate.mjs3. Interop — 500 ULIDs generated by Java were parsed by the TypeScript
implementation; decodeTime, isValid, ulidToUUID and uuidToULID round-trips all
agreed.
4. CLI parity — 12 argument scenarios produce identical output counts.
5. Cross-process parity (qc_parity/QcParity.java) — mirrors
qc_parity/main.mjs in the source repository, driving the public API through 92
deterministic observable behaviours and printing one key=value line each. Both
harnesses run in their own containers and their stdout is diffed; the output is
byte-identical. This is the check the migration QC behaviour gate consumes via
qc_fixtures.json in the source repository.