Skip to content

Commit e4fc662

Browse files
authored
Add Branded Types for Compile-Time Type Safety (#17)
* add branded types * update package
1 parent 349ba78 commit e4fc662

16 files changed

+1071
-2
lines changed

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.7.0] - 2025-09-06
11+
12+
### Added
13+
14+
- **Branded Types for TypeScript** - Compile-time type safety for validated strings
15+
- New `branded` namespace export with zero runtime overhead
16+
- Three branded types: `Email`, `URL`, `Slug`
17+
- **Type Guards**: `isValidEmail()`, `isValidUrl()`, `isSlug()` for type narrowing
18+
- **Builder Functions**: `toEmail()`, `toUrl()`, `toSlug()` for safe construction with validation
19+
- **Assertion Functions**: `assertEmail()`, `assertUrl()`, `assertSlug()` for runtime assertions with type narrowing
20+
- **Unsafe Variants**: `unsafeEmail()`, `unsafeUrl()`, `unsafeSlug()` for trusted inputs
21+
- **Utility Functions**: `ensureSlug()` for intelligent slug handling
22+
- **Custom Error Class**: `BrandedTypeError` for validation failures
23+
- Generic `Brand<K, T>` type utility for creating custom branded types
24+
- Full tree-shaking support - only imported when used
25+
- 100% test coverage with type-level tests using `expectTypeOf`
26+
- Comprehensive documentation with usage examples
27+
28+
### Performance
29+
30+
- Branded type guards have identical performance to base validators
31+
- Unsafe variants operate at ~18M ops/sec (simple type casts)
32+
- No impact on bundle size for non-TypeScript users
33+
- Bundle size remains under 6.5KB limit (6.35KB CJS)
34+
1035
## [0.6.0] - 2025-09-06
1136

1237
### Added

README.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,128 @@ Features:
753753
- Preserves original casing
754754
- Handles edge cases like unchanged plurals (sheep→sheep)
755755

756+
### Branded Types (TypeScript)
757+
758+
Nano-string-utils provides branded types for compile-time type safety with validated strings. These types add zero runtime overhead and are fully tree-shakeable.
759+
760+
```typescript
761+
import { branded } from "nano-string-utils";
762+
```
763+
764+
#### Type Guards
765+
766+
Type guards narrow string types to branded types:
767+
768+
```typescript
769+
const input: string = getUserInput();
770+
771+
if (branded.isValidEmail(input)) {
772+
// input is now typed as Email
773+
sendEmail(input);
774+
}
775+
776+
if (branded.isValidUrl(input)) {
777+
// input is now typed as URL
778+
fetch(input);
779+
}
780+
781+
if (branded.isSlug(input)) {
782+
// input is now typed as Slug
783+
useAsRoute(input);
784+
}
785+
```
786+
787+
#### Builder Functions
788+
789+
Safely create branded types with validation:
790+
791+
```typescript
792+
// Returns Email | null
793+
const email = branded.toEmail("[email protected]");
794+
if (email) {
795+
sendEmail(email); // email is typed as Email
796+
}
797+
798+
// Returns URL | null
799+
const url = branded.toUrl("https://example.com");
800+
if (url) {
801+
fetch(url); // url is typed as URL
802+
}
803+
804+
// Always returns Slug (transforms input)
805+
const slug = branded.toSlug("Hello World!"); // 'hello-world' as Slug
806+
createRoute(slug);
807+
808+
// Smart slug handling
809+
const slug2 = branded.ensureSlug("already-a-slug"); // returns as-is if valid
810+
const slug3 = branded.ensureSlug("Not A Slug!"); // transforms to 'not-a-slug'
811+
```
812+
813+
#### Assertion Functions
814+
815+
Assert types with runtime validation:
816+
817+
```typescript
818+
const input: string = getUserInput();
819+
820+
// Throws BrandedTypeError if invalid
821+
branded.assertEmail(input);
822+
// input is now typed as Email
823+
sendEmail(input);
824+
825+
// Custom error messages
826+
branded.assertUrl(input, "Invalid webhook URL");
827+
828+
// All assertion functions available
829+
branded.assertEmail(str);
830+
branded.assertUrl(str);
831+
branded.assertSlug(str);
832+
```
833+
834+
#### Unsafe Variants
835+
836+
For trusted inputs where validation isn't needed:
837+
838+
```typescript
839+
// Use only when you're certain the input is valid
840+
const trustedEmail = branded.unsafeEmail("[email protected]");
841+
const trustedUrl = branded.unsafeUrl("https://internal.api");
842+
const trustedSlug = branded.unsafeSlug("already-valid-slug");
843+
```
844+
845+
#### Available Types
846+
847+
- `Email` - Validated email addresses
848+
- `URL` - Validated URLs (http/https/ftp/ftps)
849+
- `Slug` - URL-safe slugs (lowercase, hyphenated)
850+
- `Brand<T, K>` - Generic branding utility for custom types
851+
852+
#### Benefits
853+
854+
- **Zero runtime overhead** - Types are erased at compilation
855+
- **Type safety** - Prevent passing unvalidated strings to functions
856+
- **IntelliSense support** - Full autocomplete and type hints
857+
- **Tree-shakeable** - Only imported if used
858+
- **Composable** - Works with existing string functions
859+
860+
```typescript
861+
// Example: Type-safe API
862+
function sendNewsletter(email: branded.Email) {
863+
// Can only be called with validated emails
864+
api.send(email);
865+
}
866+
867+
// Won't compile without validation
868+
const userInput = "[email protected]";
869+
// sendNewsletter(userInput); // ❌ Type error!
870+
871+
// Must validate first
872+
const validated = branded.toEmail(userInput);
873+
if (validated) {
874+
sendNewsletter(validated); // ✅ Type safe!
875+
}
876+
```
877+
756878
## Bundle Size
757879

758880
Each utility is optimized to be as small as possible:

benchmarks/branded.bench.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { bench, describe } from "vitest";
2+
import { branded, isEmail, isUrl, slugify } from "../src/index";
3+
4+
describe("Branded Types Performance", () => {
5+
const validEmail = "[email protected]";
6+
const validUrl = "https://example.com";
7+
const textToSlug = "Hello World Example";
8+
9+
bench("branded.isValidEmail", () => {
10+
branded.isValidEmail(validEmail);
11+
});
12+
13+
bench("isEmail (baseline)", () => {
14+
isEmail(validEmail);
15+
});
16+
17+
bench("branded.isValidUrl", () => {
18+
branded.isValidUrl(validUrl);
19+
});
20+
21+
bench("isUrl (baseline)", () => {
22+
isUrl(validUrl);
23+
});
24+
25+
bench("branded.isSlug", () => {
26+
branded.isSlug("hello-world");
27+
});
28+
29+
bench("branded.toEmail", () => {
30+
branded.toEmail(validEmail);
31+
});
32+
33+
bench("branded.toUrl", () => {
34+
branded.toUrl(validUrl);
35+
});
36+
37+
bench("branded.toSlug", () => {
38+
branded.toSlug(textToSlug);
39+
});
40+
41+
bench("slugify (baseline)", () => {
42+
slugify(textToSlug);
43+
});
44+
45+
bench("branded.ensureSlug (already valid)", () => {
46+
branded.ensureSlug("already-a-slug");
47+
});
48+
49+
bench("branded.ensureSlug (needs transform)", () => {
50+
branded.ensureSlug("Not A Slug");
51+
});
52+
53+
bench("branded.unsafeEmail", () => {
54+
branded.unsafeEmail(validEmail);
55+
});
56+
57+
bench("branded.unsafeUrl", () => {
58+
branded.unsafeUrl(validUrl);
59+
});
60+
61+
bench("branded.unsafeSlug", () => {
62+
branded.unsafeSlug("test-slug");
63+
});
64+
});

jsr.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@zheruel/nano-string-utils",
3-
"version": "0.6.0",
3+
"version": "0.7.0",
44
"exports": "./src/index.ts",
55
"publish": {
66
"include": ["src/**/*.ts", "README.md", "LICENSE", "CHANGELOG.md"],

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "nano-string-utils",
3-
"version": "0.6.0",
3+
"version": "0.7.0",
44
"description": "Ultra-lightweight string utilities with zero dependencies",
55
"type": "module",
66
"main": "./dist/index.cjs",

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,6 @@ export {
4949
export { pluralize } from "./pluralize.js";
5050
export { singularize } from "./singularize.js";
5151
export { memoize, type MemoizeOptions } from "./memoize.js";
52+
53+
// Branded types namespace export
54+
export * as branded from "./types/index.js";

src/types/assertions.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import {
2+
BrandedTypeError,
3+
type Email,
4+
type URL,
5+
type Slug,
6+
} from "./branded.js";
7+
import { isValidEmail, isValidUrl, isSlug } from "./guards.js";
8+
9+
/**
10+
* Asserts that a string is a valid Email, throwing if validation fails.
11+
* After this assertion, TypeScript knows the value is an Email.
12+
* @param str - The string to validate
13+
* @param message - Optional custom error message
14+
* @throws {BrandedTypeError} If the string is not a valid email
15+
* @example
16+
* const input: string = getUserInput();
17+
* assertEmail(input);
18+
* // input is now typed as Email
19+
* sendEmail(input);
20+
*/
21+
export function assertEmail(
22+
str: string,
23+
message?: string
24+
): asserts str is Email {
25+
if (!isValidEmail(str)) {
26+
throw new BrandedTypeError(message || "Email", str);
27+
}
28+
}
29+
30+
/**
31+
* Asserts that a string is a valid URL, throwing if validation fails.
32+
* After this assertion, TypeScript knows the value is a URL.
33+
* @param str - The string to validate
34+
* @param message - Optional custom error message
35+
* @throws {BrandedTypeError} If the string is not a valid URL
36+
* @example
37+
* const input: string = getUserInput();
38+
* assertUrl(input);
39+
* // input is now typed as URL
40+
* fetch(input);
41+
*/
42+
export function assertUrl(str: string, message?: string): asserts str is URL {
43+
if (!isValidUrl(str)) {
44+
throw new BrandedTypeError(message || "URL", str);
45+
}
46+
}
47+
48+
/**
49+
* Asserts that a string is a valid Slug, throwing if validation fails.
50+
* After this assertion, TypeScript knows the value is a Slug.
51+
* @param str - The string to validate
52+
* @param message - Optional custom error message
53+
* @throws {BrandedTypeError} If the string is not a valid slug
54+
* @example
55+
* const input: string = 'hello-world';
56+
* assertSlug(input);
57+
* // input is now typed as Slug
58+
* createRoute(input);
59+
*/
60+
export function assertSlug(str: string, message?: string): asserts str is Slug {
61+
if (!isSlug(str)) {
62+
throw new BrandedTypeError(message || "Slug", str);
63+
}
64+
}

src/types/branded.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* Core branded type system for compile-time type safety.
3+
* Branded types provide nominal typing in TypeScript's structural type system.
4+
*/
5+
6+
/**
7+
* Generic brand type that creates a nominal type by intersecting
8+
* with a unique brand property that only exists at the type level.
9+
*/
10+
export type Brand<K, T> = K & { __brand: T };
11+
12+
/**
13+
* Email branded type - represents a validated email address string.
14+
* Use with type guards and builder functions for type safety.
15+
* @example
16+
* const email: Email = toEmail('[email protected]')!;
17+
*/
18+
export type Email = Brand<string, "Email">;
19+
20+
/**
21+
* URL branded type - represents a validated URL string.
22+
* Use with type guards and builder functions for type safety.
23+
* @example
24+
* const url: URL = toUrl('https://example.com')!;
25+
*/
26+
export type URL = Brand<string, "URL">;
27+
28+
/**
29+
* Slug branded type - represents a URL-safe slug string.
30+
* Use with type guards and builder functions for type safety.
31+
* @example
32+
* const slug: Slug = toSlug('Hello World'); // 'hello-world'
33+
*/
34+
export type Slug = Brand<string, "Slug">;
35+
36+
/**
37+
* Type guard result type for better type inference
38+
*/
39+
export type ValidationResult<T> = T | null;
40+
41+
/**
42+
* Assertion error for branded type validation failures
43+
*/
44+
export class BrandedTypeError extends Error {
45+
constructor(type: string, value: string) {
46+
super(`Invalid ${type}: "${value}"`);
47+
this.name = "BrandedTypeError";
48+
}
49+
}

0 commit comments

Comments
 (0)