Lexicographically-sortable binary key encodings for numbers, strings, UUIDs, and composites.
TypeScript port of lexkey-rs and lexkey-dotnet, implementing the shared LexKey wire format. Encoded keys are plain Uint8Arrays where unsigned byte-wise comparison (memcmp) matches the natural ordering of the values they encode — no decoding required to sort, range-scan, or deduplicate them.
This makes LexKey a good fit for building sort keys and range-scan boundaries for key-value stores, LSM-tree engines, and any system (DynamoDB, Bigtable, FoundationDB, RocksDB, ...) where the storage layer only understands byte order.
npm install @cntryl/lexkeyimport { LexKey } from "@cntryl/lexkey";
const a = LexKey.encodeInt64(-5n);
const b = LexKey.encodeInt64(7n);
a.compareTo(b) < 0; // true — the sign-bit transform makes negative numbers sort first
LexKey.encodeString("hello").toHexString(); // "68656c6c6f"Every scalar type has a static LexKey.encodeX factory. 64-bit integers and Unix-nanosecond timestamps use bigint (JS number loses precision above 2^53); everything narrower uses plain number.
LexKey.encodeString("tenant");
LexKey.encodeUint32(42);
LexKey.encodeInt64(-123n);
LexKey.encodeFloat64(3.14);
LexKey.encodeBool(true);
LexKey.encodeUuid("550e8400-e29b-41d4-a716-446655440000");
LexKey.encodeTimeUnixNanos(BigInt(Date.now()) * 1_000_000n);Floats reject NaN (RangeError) since NaN has no defined position in a total order; -0 sorts before +0.
Join already-encoded parts with a single 0x00 separator (no trailing separator):
const key = LexKey.encodeComposite([
LexKey.encodeString("tenant").asBytes(),
LexKey.encodeUint64(42n).asBytes(),
LexKey.encodeBool(true).asBytes(),
]);For keys with several parts, build with Encoder instead — it writes every part into one growable buffer rather than allocating a standalone LexKey per part before joining them (~1.5x faster, see Benchmarks):
import { Encoder } from "@cntryl/lexkey";
const key = new Encoder()
.writeString("tenant")
.writeSeparator()
.writeUint64(42n)
.writeSeparator()
.writeBool(true)
.freeze();Encoder has a write* counterpart for every LexKey.encode*, plus writeByte, writeBytes, writeSeparator, and writeEndMarker for building the separator/marker bytes by hand.
For a partition key P and inclusive row bounds [L, U]:
const partition = LexKey.encodeString("tenant").asBytes();
const [lower, upper] = LexKey.encodeRangeBounds(partition);
// lower = P || 0x00, upper = P || 0xFF — hand these straight to a range-scan API
const rowLower = LexKey.encodeString("2026-01-01").asBytes();
const rowUpper = LexKey.encodeString("2026-02-01").asBytes();
LexKey.encodeRangeLower(partition, rowLower); // P || 0x00 || L
LexKey.encodeRangeUpper(partition, rowUpper); // P || 0x00 || U || 0xFFencodeFirst/encodeLast build the same kind of bound around an arbitrary composite prefix, and prefixSuccessor/prefixScanBounds compute the exclusive upper bound for an arbitrary raw-byte prefix scan (not just a structured 0x00/0xFF-delimited one) — see docs/SPEC.md for when each applies.
key.compareTo(other); // -1 | 0 | 1, byte-wise
key.equals(other);
key.toHexString(); // lowercase hex, useful for logging/debugging
key.asBytes(); // the underlying Uint8Array, for handing to a storage clientLexKey objects work as plain comparable values — compareTo drops straight into Array.prototype.sort, and toHexString() gives you a stable string key for Map/Set dedup.
Full generated types ship in dist/index.d.mts; see src/lexkey.ts for documented source. The test vectors double as executable usage examples for every method.
vp test benchRun this after changing any encoding path — see bench/lexkey.bench.ts. A few of the choices this implementation made after benchmarking, so you don't have to re-litigate them:
- Scalar encoders write bytes directly (bit-shifts, or a single reused scratch
DataViewfor the 64-bit/float paths) instead of allocating a freshArrayBuffer+DataViewper call. toHexString()uses the nativeUint8Array.prototype.toHex()(TC39 base64/hex methods) when the runtime has it — measured ~4x faster thanBuffer.toString("hex")and ~10x faster than a JS lookup table — falling back to a lookup table otherwise.compareBytesstays a plain JS loop even under Node:Buffer.comparewas tried and measured ~3x slower for typical short keys, since native-call overhead dominates at that size.- UUID parsing uses a hand-rolled hex-nibble table rather than
Uint8Array.fromHex, for the same reason — native-call overhead loses for 16-byte inputs.
- lexkey-rs — the reference Rust implementation and the shared wire-format spec.
- lexkey-dotnet — the .NET implementation.
This project uses Vite+ (vp) for build, lint, format, test, and benchmarking.
vp install # install dependencies
vp check # format, lint, and type-check
vp test # run tests
vp test bench # run benchmarks
vp pack # build dist/CI (.github/workflows/ci.yml) runs vp check, vp test, and vp pack on every push and pull request. Releases publish to GitHub Packages (.github/workflows/publish.yml) when a GitHub release is published.
Apache-2.0