Skip to content

slug-schema

Edit

Converts a string into a canonical lowercase ASCII slug and returns a branded value.

slug-schema.ts
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>;
Terminal
wget -O src/schemas/slug-schema.ts https://raw.githubusercontent.com/jrTilak/lazykit/HEAD/registry/zod-schemas/slug-schema.ts
slug-schema.example.ts
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)); // true
  • input (string) — Text to canonicalize into a slug.

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.