Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Validate custom alphabets in one shared place across `createId()`, `createSortableId()`, `template()`, `entropyBits()`, `sizeForEntropy()`, and `collisionProbability()`. Alphabets with duplicate characters, surrogate code units (emoji outside the BMP), or exceeding 256 characters now throw a `RangeError` instead of producing silently bad or biased IDs.
- `template()` placeholder validation now rejects non-BMP characters with the error `"prefid: \`placeholder\` must be a single BMP character."`.

## [1.2.0] - 2026-10-11

Opt-in additions only. Existing calls behave as in 1.1.2: for the same random
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ const eventId = createSortableId({ alphabet: BASE32_CROCKFORD });
eventId("evt"); // => "evt_00VQ5A1K0MBJGJFX6PWYY6WKY" — no 0/O or 1/l confusion
```

### Custom alphabets

You can pass a custom `alphabet` to `createId`, `createSortableId`, `template`, and entropy utilities.
Alphabets must contain between 2 and 256 unique characters within the Basic Multilingual Plane (BMP).
Duplicates, surrogate code units (such as emoji), and alphabets exceeding 256 characters throw a `RangeError`.
For `createSortableId`, characters must additionally be in strictly ascending code-point order to guarantee chronological sorting.

## Strict prefixes

By default any non-empty prefix without the separator is accepted. Pass
Expand Down
7 changes: 2 additions & 5 deletions src/generators/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
MAX_BATCH,
MAX_SIZE,
} from "../constants/index.js";
import { assertAlphabet } from "../internal/alphabet.js";
import { assertValidCount } from "../internal/count.js";
import { assertValidPrefix } from "../internal/prefix.js";
import { randomString, randomStrings } from "../internal/random.js";
Expand All @@ -27,11 +28,7 @@ export function createId<S extends string = "_">(
`prefid: \`size\` must be an integer between 1 and ${MAX_SIZE}.`,
);
}
if (typeof alphabet !== "string" || alphabet.length < 2) {
throw new RangeError(
"prefid: `alphabet` must contain at least 2 characters.",
);
}
assertAlphabet(alphabet);
if (typeof separator !== "string" || separator.length === 0) {
throw new TypeError("prefid: `separator` must be a non-empty string.");
}
Expand Down
7 changes: 2 additions & 5 deletions src/generators/sortable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
MAX_SIZE,
SORTABLE_TIME_MAX,
} from "../constants/index.js";
import { assertAlphabet } from "../internal/alphabet.js";
import { assertValidCount } from "../internal/count.js";
import { assertValidPrefix } from "../internal/prefix.js";
import { randomIndices } from "../internal/random.js";
Expand Down Expand Up @@ -113,11 +114,7 @@ export function createSortableId<S extends string = "_">(
const clock = options.now ?? Date.now;
const randomSize = options.randomSize ?? DEFAULT_SORTABLE_RANDOM_SIZE;

if (typeof alphabet !== "string" || alphabet.length < 2) {
throw new RangeError(
"prefid: `alphabet` must contain at least 2 characters.",
);
}
assertAlphabet(alphabet);
if (typeof separator !== "string" || separator.length === 0) {
throw new TypeError("prefid: `separator` must be a non-empty string.");
}
Expand Down
13 changes: 8 additions & 5 deletions src/generators/template.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DEFAULT_ALPHABET, MAX_SIZE } from "../constants/index.js";
import { assertAlphabet, SURROGATE_PATTERN } from "../internal/alphabet.js";
import { randomString } from "../internal/random.js";

export interface TemplateOptions {
Expand All @@ -25,14 +26,16 @@ export function template(
if (typeof pattern !== "string" || pattern.length === 0) {
throw new TypeError("prefid: template pattern must be a non-empty string.");
}
if (typeof placeholder !== "string" || placeholder.length !== 1) {
throw new RangeError("prefid: `placeholder` must be a single character.");
}
if (typeof alphabet !== "string" || alphabet.length < 2) {
if (
typeof placeholder !== "string" ||
placeholder.length !== 1 ||
SURROGATE_PATTERN.test(placeholder)
) {
throw new RangeError(
"prefid: `alphabet` must contain at least 2 characters.",
"prefid: `placeholder` must be a single BMP character.",
);
}
assertAlphabet(alphabet);

let slots = 0;
for (const char of pattern) {
Expand Down
26 changes: 26 additions & 0 deletions src/internal/alphabet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const MIN_ALPHABET_LENGTH = 2;
const MAX_ALPHABET_LENGTH = 256;
export const SURROGATE_PATTERN = /[\uD800-\uDFFF]/;

export function assertAlphabet(alphabet: unknown): asserts alphabet is string {
if (typeof alphabet !== "string" || alphabet.length < MIN_ALPHABET_LENGTH) {
throw new RangeError(
"prefid: `alphabet` must contain at least 2 characters.",
);
}
if (alphabet.length > MAX_ALPHABET_LENGTH) {
throw new RangeError(
`prefid: \`alphabet\` must not contain more than ${MAX_ALPHABET_LENGTH} characters.`,
);
}
if (SURROGATE_PATTERN.test(alphabet)) {
throw new RangeError(
"prefid: `alphabet` must not contain surrogate code units.",
);
}
if (new Set(alphabet).size !== alphabet.length) {
throw new RangeError(
"prefid: `alphabet` must not contain duplicate characters.",
);
}
}
9 changes: 1 addition & 8 deletions src/utils/entropy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,9 @@ import {
DEFAULT_SIZE,
MAX_SIZE,
} from "../constants/index.js";
import { assertAlphabet } from "../internal/alphabet.js";
import type { IdOptions } from "../types/index.js";

function assertAlphabet(alphabet: unknown): asserts alphabet is string {
if (typeof alphabet !== "string" || alphabet.length < 2) {
throw new RangeError(
"prefid: `alphabet` must contain at least 2 characters.",
);
}
}

function resolve(options: IdOptions): { size: number; radix: number } {
const size = options.size ?? DEFAULT_SIZE;
const alphabet = options.alphabet ?? DEFAULT_ALPHABET;
Expand Down
47 changes: 47 additions & 0 deletions test/alphabet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { assertAlphabet } from "../src/internal/alphabet.js";

describe("assertAlphabet()", () => {
it("accepts valid alphabets between 2 and 256 unique BMP characters", () => {
expect(() => assertAlphabet("ab")).not.toThrow();
expect(() => assertAlphabet("0123456789abcdef")).not.toThrow();
expect(() => assertAlphabet("абвгдежзийклмноп")).not.toThrow();
expect(() => assertAlphabet("αβγδεζηθικλμνξοπ")).not.toThrow();

const maxAlphabet = Array.from({ length: 256 }, (_, i) =>
String.fromCharCode(i < 128 ? i + 33 : i + 200),
).join("");
expect(maxAlphabet.length).toBe(256);
expect(() => assertAlphabet(maxAlphabet)).not.toThrow();
});

it("rejects non-strings or alphabets with fewer than 2 characters", () => {
expect(() => assertAlphabet(undefined)).toThrow(RangeError);
expect(() => assertAlphabet(null)).toThrow(RangeError);
expect(() => assertAlphabet(123)).toThrow(RangeError);
expect(() => assertAlphabet("")).toThrow(RangeError);
expect(() => assertAlphabet("a")).toThrow(RangeError);
});

it("rejects alphabets longer than 256 characters", () => {
const longAlphabet = Array.from({ length: 257 }, (_, i) =>
String.fromCharCode(0x0400 + i),
).join("");
expect(longAlphabet.length).toBe(257);
expect(() => assertAlphabet(longAlphabet)).toThrow(RangeError);
});

it("rejects alphabets with surrogate code units (emoji / characters outside BMP)", () => {
expect(() => assertAlphabet("😀😁🙂🙃")).toThrow(RangeError);
expect(() => assertAlphabet("ab\uD800c")).toThrow(RangeError);
expect(() => assertAlphabet("ab\uDFFFc")).toThrow(RangeError);
expect(() => assertAlphabet("abc🎉")).toThrow(RangeError);
});

it("rejects alphabets with duplicate characters", () => {
expect(() => assertAlphabet("aa")).toThrow(RangeError);
expect(() => assertAlphabet("aab")).toThrow(RangeError);
expect(() => assertAlphabet("aba")).toThrow(RangeError);
expect(() => assertAlphabet("01234567890")).toThrow(RangeError);
});
});
32 changes: 32 additions & 0 deletions test/entropy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ describe("entropyBits()", () => {
expect(() => entropyBits({ size: 1.5 })).toThrow(RangeError);
expect(() => entropyBits({ size: 4097 })).toThrow(RangeError);
expect(() => entropyBits({ alphabet: "a" })).toThrow(RangeError);
expect(() => entropyBits({ alphabet: "aa" })).toThrow(RangeError);
expect(() => entropyBits({ alphabet: "😀😁" })).toThrow(RangeError);
expect(() =>
entropyBits({
alphabet: Array.from({ length: 257 }, (_, i) =>
String.fromCharCode(0x0400 + i),
).join(""),
}),
).toThrow(RangeError);
});
});

Expand All @@ -47,6 +56,16 @@ describe("sizeForEntropy()", () => {
expect(() => sizeForEntropy(0)).toThrow(RangeError);
expect(() => sizeForEntropy(Number.NaN)).toThrow(RangeError);
expect(() => sizeForEntropy(64, "a")).toThrow(RangeError);
expect(() => sizeForEntropy(64, "aa")).toThrow(RangeError);
expect(() => sizeForEntropy(64, "😀😁")).toThrow(RangeError);
expect(() =>
sizeForEntropy(
64,
Array.from({ length: 257 }, (_, i) =>
String.fromCharCode(0x0400 + i),
).join(""),
),
).toThrow(RangeError);
});
});

Expand Down Expand Up @@ -80,5 +99,18 @@ describe("collisionProbability()", () => {
expect(() => collisionProbability(10, { alphabet: "a" })).toThrow(
RangeError,
);
expect(() => collisionProbability(10, { alphabet: "aa" })).toThrow(
RangeError,
);
expect(() => collisionProbability(10, { alphabet: "😀😁" })).toThrow(
RangeError,
);
expect(() =>
collisionProbability(10, {
alphabet: Array.from({ length: 257 }, (_, i) =>
String.fromCharCode(0x0400 + i),
).join(""),
}),
).toThrow(RangeError);
});
});
22 changes: 22 additions & 0 deletions test/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,28 @@ describe("createId()", () => {
expect(() => createId({ alphabet: "x" })).toThrow(RangeError);
});

it("rejects an alphabet longer than 256 characters", () => {
const long = Array.from({ length: 257 }, (_, i) =>
String.fromCharCode(0x0400 + i),
).join("");
expect(() => createId({ alphabet: long })).toThrow(RangeError);
});

it("rejects an alphabet with surrogate code units (emoji)", () => {
expect(() => createId({ alphabet: "😀😁🙂🙃" })).toThrow(RangeError);
expect(() => createId({ alphabet: "ab\uD800c" })).toThrow(RangeError);
});

it("rejects an alphabet with duplicate characters", () => {
expect(() => createId({ alphabet: "aa" })).toThrow(RangeError);
expect(() => createId({ alphabet: "aab" })).toThrow(RangeError);
});

it("accepts valid custom alphabets between 2 and 256 characters", () => {
const gen = createId({ alphabet: "01" });
expect(gen("u")).toMatch(/^u_[01]+$/);
});

it("rejects an empty separator", () => {
expect(() => createId({ separator: "" })).toThrow(TypeError);
});
Expand Down
14 changes: 14 additions & 0 deletions test/sortable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,20 @@ describe("createSortableId() — validation", () => {
expect(() => createSortableId({ alphabet: "x" })).toThrow(RangeError);
});

it("rejects an alphabet longer than 256 characters", () => {
const long = Array.from({ length: 257 }, (_, i) =>
String.fromCharCode(0x0400 + i),
).join("");
expect(() => createSortableId({ alphabet: long })).toThrow(RangeError);
});

it("rejects an alphabet with surrogate code units (emoji)", () => {
expect(() => createSortableId({ alphabet: "😀😁" })).toThrow(RangeError);
expect(() => createSortableId({ alphabet: "ab\uD800" })).toThrow(
RangeError,
);
});

it("rejects an empty separator", () => {
expect(() => createSortableId({ separator: "" })).toThrow(TypeError);
});
Expand Down
23 changes: 22 additions & 1 deletion test/template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,35 @@ describe("template()", () => {
expect(gen().length).toBe(4096);
});

it("throws on a multi-character placeholder", () => {
it("throws on a multi-character or non-BMP placeholder", () => {
expect(() => template("a##", { placeholder: "##" })).toThrow(RangeError);
expect(() => template("a##", { placeholder: "" })).toThrow(RangeError);
expect(() => template("a##", { placeholder: "😀" })).toThrow(RangeError);
expect(() => template("a##", { placeholder: "\uD800" })).toThrow(
RangeError,
);
});

it("throws on an alphabet shorter than 2 characters", () => {
expect(() => template("a#", { alphabet: "x" })).toThrow(RangeError);
});

it("throws on an alphabet longer than 256 characters", () => {
const long = Array.from({ length: 257 }, (_, i) =>
String.fromCharCode(0x0400 + i),
).join("");
expect(() => template("a#", { alphabet: long })).toThrow(RangeError);
});

it("throws on an alphabet with surrogate code units (emoji)", () => {
expect(() => template("a#", { alphabet: "😀😁" })).toThrow(RangeError);
});

it("throws on an alphabet with duplicate characters", () => {
expect(() => template("a#", { alphabet: "aa" })).toThrow(RangeError);
expect(() => template("a#", { alphabet: "aba" })).toThrow(RangeError);
});

it("preserves the literal prefix in the type", () => {
const value = template("user_####")();
const typed: `user_${string}` = value;
Expand Down