From 748e12f9149ce457a936b80684d1a8b4caaaa6ce Mon Sep 17 00:00:00 2001 From: Miguel Date: Fri, 9 Oct 2026 15:23:39 -0300 Subject: [PATCH 1/2] feat: add opt-in strictPrefix option createId and createSortableId accept strictPrefix. When true, the prefix must be 1-63 ASCII letters or digits; otherwise a TypeError names the first offending character. Default behavior is unchanged. --- CHANGELOG.md | 4 ++++ README.md | 14 ++++++++++++++ src/generators/generate.ts | 3 ++- src/generators/sortable.ts | 4 +++- src/internal/prefix.ts | 25 ++++++++++++++++++++++++- src/types/index.ts | 1 + test/generate.test.ts | 32 ++++++++++++++++++++++++++++++++ test/sortable.test.ts | 17 +++++++++++++++++ 8 files changed, 97 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b69c95..dc3fa74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. + ## [1.1.2] - 2026-10-09 No changes to the published code: `dist/` is byte-identical to 1.1.1. This release diff --git a/README.md b/README.md index 70eae47..4908755 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: 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..1c967af 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 = false, +): 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 must be at most ${STRICT_MAX_LENGTH} characters in strict mode.`, + ); + } + } } 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..ff5d54d 100644 --- a/test/generate.test.ts +++ b/test/generate.test.ts @@ -85,3 +85,35 @@ 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("allows 63 characters and rejects 64", () => { + expect(() => strict("a".repeat(63))).not.toThrow(); + expect(() => strict("a".repeat(64))).toThrow(TypeError); + }); + + 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); + }); +}); From d4e2df7d405be927ada6221620c83337bd775a1b Mon Sep 17 00:00:00 2001 From: Miguel Date: Sun, 11 Oct 2026 09:30:21 -0300 Subject: [PATCH 2/2] refactor: require the strict flag in assertValidPrefix Make strict a required parameter so callers that forget it fail to compile instead of silently skipping the check. Cover an emoji prefix, show the full error text in the README, and report the prefix length in the 64-character error. --- README.md | 2 +- src/internal/prefix.ts | 4 ++-- test/generate.test.ts | 8 +++++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4908755..5c7def5 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ import { createId } from "prefid"; const id = createId({ strictPrefix: true }); id("user"); // => "user_a8Kd0f2bQ1nR7pZ3xW4mT6y" -id("us er"); // throws: prefix "us er" contains an invalid character " " at index 2. +id("us er"); // throws TypeError: prefid: prefix "us er" contains an invalid character " " at index 2. ``` ## Install diff --git a/src/internal/prefix.ts b/src/internal/prefix.ts index 1c967af..7332b5e 100644 --- a/src/internal/prefix.ts +++ b/src/internal/prefix.ts @@ -4,7 +4,7 @@ const NON_ALPHANUMERIC = /[^A-Za-z0-9]/; export function assertValidPrefix( prefix: string, separator: string, - strict = false, + strict: boolean, ): void { if (typeof prefix !== "string" || prefix.length === 0) { throw new TypeError("prefid: prefix must be a non-empty string."); @@ -26,7 +26,7 @@ export function assertValidPrefix( } if (prefix.length > STRICT_MAX_LENGTH) { throw new TypeError( - `prefid: prefix must be at most ${STRICT_MAX_LENGTH} characters in strict mode.`, + `prefid: prefix is ${prefix.length} characters long; strict mode allows at most ${STRICT_MAX_LENGTH}.`, ); } } diff --git a/test/generate.test.ts b/test/generate.test.ts index ff5d54d..4e64ccb 100644 --- a/test/generate.test.ts +++ b/test/generate.test.ts @@ -107,9 +107,15 @@ describe("strictPrefix", () => { 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(TypeError); + expect(() => strict("a".repeat(64))).toThrow( + "prefid: prefix is 64 characters long; strict mode allows at most 63.", + ); }); it("leaves default mode unchanged", () => {