slug-schema
EditConverts a string into a canonical lowercase ASCII slug and returns a branded value.
import * as z from "zod";
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f-\u009f]/u;const isWellFormedUnicode = (value: string): boolean => { for (let index = 0; index < value.length; index += 1) { const codeUnit = value.charCodeAt(index); if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { const nextCodeUnit = value.charCodeAt(index + 1); if (!(nextCodeUnit >= 0xdc00 && nextCodeUnit <= 0xdfff)) return false; index += 1; } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { return false; } } return true;};
const canonicalizeSlug = (value: string): string => value .trim() .normalize("NFKD") .replace(/\p{M}+/gu, "") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "");
/** Canonicalizes text into a validated, branded URL slug. */export const slugSchema = z .string({ error: "Slug input must be a string." }) .check( z.refine(isWellFormedUnicode, { error: "Slug input must contain only well-formed Unicode.", abort: true, }), z.refine((value) => !CONTROL_CHARACTER_PATTERN.test(value), { error: "Slug input must not contain control characters.", abort: true, }), ) .transform(canonicalizeSlug) .pipe( z .string() .min(1, { error: "Slug must contain at least one letter or number.", abort: true, }) .max(100, { error: "Slug must be at most 100 characters.", }) .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { error: "Slug must contain lowercase letters and numbers separated by single hyphens.", }) .brand<"Slug">(), ) .meta({ title: "Slug", description: "A canonical lowercase URL slug containing ASCII letters, numbers, and single hyphens.", examples: ["introducing-zod-4", "release-notes-2026"], });
export type Slug = z.infer<typeof slugSchema>;Download
Section titled “Download”wget -O src/schemas/slug-schema.ts https://raw.githubusercontent.com/jrTilak/lazykit/HEAD/registry/zod-schemas/slug-schema.tstemp_dir="$(mktemp -d)" && bunx degit jrTilak/lazykit/registry/zod-schemas "$temp_dir" && mv "$temp_dir/slug-schema.ts" src/schemas/slug-schema.ts && rm -r "$temp_dir"Examples
Section titled “Examples”import { slugSchema, type Slug } from "./slug-schema";
const articleSlug: Slug = slugSchema.parse(" Crème Brûlée for Beginners ");
// "creme-brulee-for-beginners"console.log(articleSlug);
const result = slugSchema.safeParse("Release Notes: Zod 4");
if (result.success) { // "release-notes-zod-4" console.log(result.data);}
const alternateSlug = slugSchema.parse("Crème---Brûlée for Beginners!");
// Canonicalization is many-to-one, so check the final value for uniqueness.const existingSlugs = new Set<Slug>([articleSlug]);console.log(existingSlugs.has(alternateSlug)); // trueinput(string) — Text to canonicalize into a slug.
Returns
Section titled “Returns”Slug, a string branded with "Slug". The input is trimmed, normalized to
Unicode NFKD, stripped of combining marks, lowercased, and converted to ASCII
letters and numbers separated by single hyphens. Leading and trailing hyphens
are removed.
Before canonicalization, the schema rejects unpaired UTF-16 surrogates and C0 or C1 control characters, including tabs and line breaks. Ordinary leading and trailing spaces remain valid and are trimmed. Punctuation remains valid input and is converted into separators.
The canonical output must contain 1 to 100 characters. Inputs that contain no
ASCII letter or number after canonicalization are rejected. Decomposable
diacritics such as è become their ASCII base letter; scripts without an ASCII
decomposition are removed rather than transliterated.
Canonicalization is many-to-one: inputs such as Hello, World! and
hello---world both produce hello-world. The brand proves that the returned
value passed this schema, not that it is unique. Applications must check the
final canonical slug against their own routes or stored records.