Skip to content

ULID Generator — JavaScript source

Generate Universally Unique Lexicographically Sortable Identifiers (ULID) - 26-character Crockford-base32 strings that sort by millisecond timestamp. Paste any ULID to decode its timestamp and randomness. Runs entirely in your browser.

This is the JavaScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

'use strict';
/**
 * ulid-generator - Universally Unique Lexicographically Sortable IDentifier.
 *
 * Language: JavaScript (Node 14+, standard library only)
 * Source:   CosmoDev polyglot showcase port of the ULID Generator tool, ported
 *           from cli/ulid-generator/ulid-generator.go (the hand-rolled Go twin
 *           of src/lib/ulid.ts - the canonical TypeScript implementation).
 * License:  display source - part of CosmoDev's polyglot tool pages.
 *
 * Design goals:
 *   - Pure + deterministic; the generator never throws (decode throws on
 *     malformed input, matching the TS `throw` and Go error).
 *   - Functionally equivalent to the Go twin: same inputs -> same outputs for
 *     the timestamp prefix, which is the cross-language lock-step anchor.
 *   - Self-contained: stdlib only (no npm dependencies - no `ulid` package).
 *
 * A ULID is 26 Crockford-base32 chars: the first 10 encode a 48-bit millisecond
 * timestamp (MSB-first) and the last 16 encode 80 bits of randomness. Crockford
 * alphabet: "0123456789ABCDEFGHJKMNPQRSTVWXYZ" (excludes I, L, O, U).
 *
 * Time-encoding is the lock-step ANCHOR. Because 32^16 === 2^80 exactly,
 * packing [6 time bytes | 10 random bytes] into a 16-byte big-endian integer
 * and base32-encoding the whole 128-bit value reproduces the Go twin's separate
 * time encoding byte-for-byte in the first 10 chars - so
 * decodeUlidTime(generateUlid(ms)) === ms holds identically. The random tail is
 * drawn from crypto.randomBytes (a Node stdlib CSPRNG, the JS analogue of Go's
 * crypto/rand) and varies call to call, exactly as in the Go default-RNG path.
 *
 * Bit-width note: JS bitwise operators are 32-bit, so a 48-bit timestamp
 * cannot be sliced with `>>`. We extract each byte with plain arithmetic
 * (Math.floor division + modulo) - exact for every integer up to 2^53, which
 * comfortably covers the 48-bit timestamp field.
 */
const crypto = require('crypto');

/** The Crockford base32 alphabet (excludes I, L, O, U). */
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';

/**
 * Map from an ASCII char CODE to its Crockford value. Built once. Accepts
 * lowercase a-z where the uppercase equivalent is valid; i/l/o/u are absent
 * because I/L/O/U are not in the alphabet - matching the `ulid` npm package's
 * DECODING table, so a lowercase TS-produced ULID decodes the same way here.
 * Keys are char codes (numbers) so the case fold happens at build time, not on
 * every lookup.
 */
const DECODE = (() => {
  const map = new Map();
  for (let i = 0; i < CROCKFORD.length; i++) map.set(CROCKFORD.charCodeAt(i), i);
  for (let c = 97; c <= 122; c++) {
    // 97..122 = 'a'..'z'; the uppercase equivalent is c - 32.
    const up = c - 32;
    if (map.has(up)) map.set(c, map.get(up));
  }
  return map;
})();

/**
 * Divide the big-endian integer stored in `b` (mutated in place) by 32 and
 * return the remainder (0-31). Long division in base 256: each byte is
 * combined with the carry from the previous (more-significant) byte, the high
 * 8 bits become the new byte and the low 5 bits become the next carry.
 *
 * Values stay well under 2^16 (max `(31 << 8) | 255 === 8191`), so the 32-bit
 * JS bitwise operators are exact here. Mirrors `divBy32` in the Go twin so the
 * base32 digits come out identically.
 */
function divBy32(b) {
  let rem = 0;
  for (let i = 0; i < b.length; i++) {
    const cur = (rem << 8) | b[i];
    b[i] = cur >> 5;
    rem = cur & 0x1f;
  }
  return rem;
}

/**
 * Encode a 48-bit millisecond timestamp into 6 big-endian bytes (MSB first).
 * Uses arithmetic rather than `>>` because JS bitwise ops are 32-bit and the
 * timestamp can span the full 48-bit field. Exposed so showcase tests can build
 * a deterministic `encodeUlid` input without touching the RNG.
 *
 * @param {number} ms  millisecond timestamp (integer, 0 .. 2^48-1)
 * @returns {number[]}
 */
function timeBytes(ms) {
  return [
    Math.floor(ms / 0x10000000000) % 256, // >> 40
    Math.floor(ms / 0x100000000) % 256, //   >> 32
    Math.floor(ms / 0x1000000) % 256, //     >> 24
    Math.floor(ms / 0x10000) % 256, //       >> 16
    Math.floor(ms / 0x100) % 256, //         >> 8
    ms % 256,
  ];
}

/**
 * Pure, deterministic ULID encoder: base32-encode the 128-bit big-endian value
 * `[timeBytes | randomBytes]` into the canonical 26 chars. No RNG, no time
 * source - the showcase tests assert exact strings through this function.
 *
 * @param {number[]} timeBytes    exactly 6 bytes
 * @param {number[]} randomBytes  exactly 10 bytes
 * @returns {string}
 */
function encodeUlid(timeBytes, randomBytes) {
  const b = timeBytes.concat(randomBytes); // 16 bytes
  const out = new Array(26);
  // Repeatedly divide the 128-bit value by 32, collecting remainders LSB-first
  // into out[25] down to out[0]. 26 base32 digits cover 130 bits, so the most
  // significant digit captures the leftover top 3 bits (0-7).
  for (let i = 25; i >= 0; i--) {
    out[i] = CROCKFORD[divBy32(b)];
  }
  return out.join('');
}

/**
 * Generate a new ULID for the given millisecond timestamp. The Go twin of
 * generateUlid() in src/lib/ulid.ts. Never throws: `ms` is truncated to its
 * low 48 bits; negative values encode the low 48 bits of the two's-complement
 * representation (defined but not meaningful).
 *
 * @param {number} ms  millisecond timestamp
 * @returns {string}
 */
function generateUlid(ms) {
  const randomBytes = Array.from(crypto.randomBytes(10)); // Node stdlib CSPRNG
  return encodeUlid(timeBytes(ms), randomBytes);
}

/**
 * Extract the 48-bit millisecond timestamp encoded in the first 10 chars of
 * `id`. The Go twin of decodeUlidTime() in src/lib/ulid.ts. Throws if `id` is
 * not exactly 26 chars or contains a character outside the Crockford base32
 * alphabet (I, L, O, U are invalid) - erroring on the same inputs as the `ulid`
 * npm package's decodeTime.
 *
 * @param {string} id
 * @returns {number}
 */
function decodeUlidTime(id) {
  if (id.length !== 26) {
    throw new Error(`ulid: malformed id: length ${id.length}, want 26`);
  }
  let ts = 0;
  for (let i = 0; i < 10; i++) {
    const v = DECODE.get(id.charCodeAt(i));
    if (v === undefined) {
      throw new Error(`ulid: invalid character '${id[i]}' at position ${i}`);
    }
    ts = ts * 32 + v; // ts stays under 2^48 (well within Number safe range)
  }
  return ts;
}

// CommonJS export so the file is consumable from Node without a build step,
// while staying dependency-free and framework-agnostic.
module.exports = {
  CROCKFORD,
  timeBytes,
  encodeUlid,
  generateUlid,
  decodeUlidTime,
};

// ---------- showcase tests (the canonical suite lives in src/lib) ----------
// Run only when invoked directly: `node ulid-generator.js`.
if (require.main === module) {
  const zeros = new Array(10).fill(0);
  const ulidRe = /^[0-9A-HJKMNP-TV-Z]{26}$/;

  // All-zero input -> all-zero output; the canonical base32 of 0.
  console.assert(encodeUlid(timeBytes(0), zeros) === '0'.repeat(26), 'zero encode');

  // The lock-step anchor: decode(encode(ms)) === ms, regardless of the random
  // tail. Verified across several byte boundaries in the 48-bit time field.
  for (const ms of [0, 1, 42, 150000, 1000000, 2000000, (1 << 48) - 1]) {
    const round = decodeUlidTime(encodeUlid(timeBytes(ms), zeros));
    console.assert(round === ms, `round-trip failed for ms=${ms}`);
  }

  // 150000 base32 in Crockford is "4JFG" -> zero-padded to 10 chars.
  console.assert(encodeUlid(timeBytes(150000), zeros).slice(0, 10) === '0000004JFG', 'known prefix');

  // generateUlid produces a well-formed 26-char Crockford ULID that round-trips.
  const gid = generateUlid(150000);
  console.assert(ulidRe.test(gid), 'generate format');
  console.assert(decodeUlidTime(gid) === 150000, 'generate round-trip');

  // The time prefix is the most-significant part of the string, so an older
  // timestamp sorts before a newer one regardless of the tail.
  console.assert(generateUlid(1000000) < generateUlid(2000000), 'lexicographic order');

  // Malformed input throws (the canonical TS vector + an excluded letter).
  let threw = false;
  try {
    decodeUlidTime('not-a-ulid');
  } catch {
    threw = true;
  }
  console.assert(threw, 'invalid throws');

  console.log('ulid-generator showcase: all assertions passed');
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →