diff --git a/CHANGELOG.md b/CHANGELOG.md
index 89464c4..3453ca8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- `strictPrefix` option on `createId()` and `createSortableId()`. When `true`, the prefix must be 1-63 ASCII letters or digits, otherwise a `TypeError` names the first offending character. Off by default, so existing behavior is unchanged.
- `entropyBits(options?)`, `sizeForEntropy(bits, alphabet?)` and `collisionProbability(count, options?)` compute the entropy and birthday-collision probability of an ID configuration. `collisionProbability` works in log space with `Math.expm1`, so realistic inputs do not round to 0.
## [1.1.2] - 2026-10-09
diff --git a/README.md b/README.md
index 70eae47..5c7def5 100644
--- a/README.md
+++ b/README.md
@@ -57,6 +57,20 @@ const eventId = createSortableId({ alphabet: BASE32_CROCKFORD });
eventId("evt"); // => "evt_00VQ5A1K0MBJGJFX6PWYY6WKY" — no 0/O or 1/l confusion
```
+## Strict prefixes
+
+By default any non-empty prefix without the separator is accepted. Pass
+`strictPrefix: true` to `createId` or `createSortableId` to require 1-63 ASCII
+letters or digits, so a stray space or typo can't end up in your database:
+
+```ts
+import { createId } from "prefid";
+
+const id = createId({ strictPrefix: true });
+id("user"); // => "user_a8Kd0f2bQ1nR7pZ3xW4mT6y"
+id("us er"); // throws TypeError: prefid: prefix "us er" contains an invalid character " " at index 2.
+```
+
## Install
```bash
diff --git a/src/generators/generate.ts b/src/generators/generate.ts
index 8d3823c..d181b9d 100644
--- a/src/generators/generate.ts
+++ b/src/generators/generate.ts
@@ -14,6 +14,7 @@ export function createId(
const size = defaults.size ?? DEFAULT_SIZE;
const separator = defaults.separator ?? DEFAULT_SEPARATOR;
const alphabet = defaults.alphabet ?? DEFAULT_ALPHABET;
+ const strictPrefix = defaults.strictPrefix ?? false;
if (!Number.isInteger(size) || size < 1 || size > MAX_SIZE) {
throw new RangeError(
@@ -30,7 +31,7 @@ export function createId(
}
return function id
(prefix: P): PrefixedId
{
- assertValidPrefix(prefix, separator);
+ assertValidPrefix(prefix, separator, strictPrefix);
return `${prefix}${separator}${randomString(alphabet, size)}` as PrefixedId<
P,
S
diff --git a/src/generators/sortable.ts b/src/generators/sortable.ts
index eb119de..bb516ea 100644
--- a/src/generators/sortable.ts
+++ b/src/generators/sortable.ts
@@ -16,6 +16,7 @@ export interface SortableIdOptions {
randomSize?: number;
timestampSize?: number;
monotonic?: boolean;
+ strictPrefix?: boolean;
now?: () => number;
}
@@ -106,6 +107,7 @@ export function createSortableId(
const separator = options.separator ?? DEFAULT_SEPARATOR;
const alphabet = options.alphabet ?? DEFAULT_ALPHABET;
const monotonic = options.monotonic ?? true;
+ const strictPrefix = options.strictPrefix ?? false;
const clock = options.now ?? Date.now;
const randomSize = options.randomSize ?? DEFAULT_SORTABLE_RANDOM_SIZE;
@@ -131,7 +133,7 @@ export function createSortableId(
let lastRandom: number[] = [];
return function sortableId
(prefix: P): PrefixedId
{
- assertValidPrefix(prefix, separator);
+ assertValidPrefix(prefix, separator, strictPrefix);
const reading = clock();
if (typeof reading !== "number" || !Number.isFinite(reading)) {
diff --git a/src/internal/prefix.ts b/src/internal/prefix.ts
index 6c49c8b..7332b5e 100644
--- a/src/internal/prefix.ts
+++ b/src/internal/prefix.ts
@@ -1,4 +1,11 @@
-export function assertValidPrefix(prefix: string, separator: string): void {
+const STRICT_MAX_LENGTH = 63;
+const NON_ALPHANUMERIC = /[^A-Za-z0-9]/;
+
+export function assertValidPrefix(
+ prefix: string,
+ separator: string,
+ strict: boolean,
+): void {
if (typeof prefix !== "string" || prefix.length === 0) {
throw new TypeError("prefid: prefix must be a non-empty string.");
}
@@ -7,4 +14,20 @@ export function assertValidPrefix(prefix: string, separator: string): void {
`prefid: prefix "${prefix}" must not contain the separator "${separator}".`,
);
}
+ if (strict) {
+ const bad = NON_ALPHANUMERIC.exec(prefix);
+ if (bad) {
+ const char = String.fromCodePoint(
+ prefix.codePointAt(bad.index) as number,
+ );
+ throw new TypeError(
+ `prefid: prefix "${prefix}" contains an invalid character "${char}" at index ${bad.index}.`,
+ );
+ }
+ if (prefix.length > STRICT_MAX_LENGTH) {
+ throw new TypeError(
+ `prefid: prefix is ${prefix.length} characters long; strict mode allows at most ${STRICT_MAX_LENGTH}.`,
+ );
+ }
+ }
}
diff --git a/src/types/index.ts b/src/types/index.ts
index d3ac1b7..747089b 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -7,6 +7,7 @@ export interface IdOptions {
size?: number;
separator?: string;
alphabet?: string;
+ strictPrefix?: boolean;
}
export type IdGenerator =
( diff --git a/test/generate.test.ts b/test/generate.test.ts index c83f306..4e64ccb 100644 --- a/test/generate.test.ts +++ b/test/generate.test.ts @@ -85,3 +85,41 @@ describe("createId()", () => { expect(() => createId({ separator: "" })).toThrow(TypeError); }); }); + +describe("strictPrefix", () => { + const strict = createId({ strictPrefix: true }); + + it("accepts alphanumeric prefixes", () => { + expect(strict("user2").startsWith("user2_")).toBe(true); + }); + + it("rejects a space and names the character and index", () => { + expect(() => strict("us er")).toThrow( + 'prefid: prefix "us er" contains an invalid character " " at index 2.', + ); + }); + + it("rejects punctuation", () => { + expect(() => strict("us-er")).toThrow(TypeError); + }); + + it("rejects non-ASCII characters", () => { + expect(() => strict("usér")).toThrow(/invalid character "é" at index 2/); + }); + + it("names a whole emoji, not half of it", () => { + expect(() => strict("ab😀")).toThrow(/invalid character "😀" at index 2/); + }); + + it("allows 63 characters and rejects 64", () => { + expect(() => strict("a".repeat(63))).not.toThrow(); + expect(() => strict("a".repeat(64))).toThrow( + "prefid: prefix is 64 characters long; strict mode allows at most 63.", + ); + }); + + it("leaves default mode unchanged", () => { + expect(id("us er!ü").startsWith("us er!ü_")).toBe(true); + expect(createId()("a".repeat(64)).startsWith("a".repeat(64))).toBe(true); + }); +}); diff --git a/test/sortable.test.ts b/test/sortable.test.ts index f29c47d..e168310 100644 --- a/test/sortable.test.ts +++ b/test/sortable.test.ts @@ -375,3 +375,20 @@ describe("getDate()", () => { expect(getDate(at(MAX_DATE_MS))).toBeUndefined(); }); }); + +describe("createSortableId strictPrefix", () => { + const strict = createSortableId({ strictPrefix: true }); + + it("accepts alphanumeric prefixes", () => { + expect(strict("evt").startsWith("evt_")).toBe(true); + }); + + it("rejects invalid characters and over-long prefixes", () => { + expect(() => strict("e vt")).toThrow(/invalid character " " at index 1/); + expect(() => strict("a".repeat(64))).toThrow(TypeError); + }); + + it("leaves default mode unchanged", () => { + expect(createSortableId()("e vt").startsWith("e vt_")).toBe(true); + }); +});