Implementing UUIDs in TypeScript
You've launched your app and users are creating accounts. Two users sign up at the exact same millisecond, and your auto-incrementing integer ID system assigns them both ID 12847. Now you've got duplicate records, broken foreign keys, and a corrupted database. This is where UUIDs become critical—they're 128-bit identifiers designed to be globally unique, even across distributed systems.
Choosing the Right UUID Version
Not all UUIDs are created equal. The UUID spec defines multiple versions, each optimized for different use cases. Here's when to use each:
UUID v4 (Random)
Version 4 generates completely random identifiers using cryptographically secure random number generation:
import { v4 as uuidv4 } from 'uuid';
const userId = uuidv4();
console.log(userId); // "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
When to use v4:
- General-purpose unique identifiers
- API keys or session tokens
- When you don't need sorting or chronological ordering
- Default choice if you're unsure
Trade-offs: While collision probability is astronomically low (you'd need to generate billions of IDs per second for years), v4 UUIDs are completely random, which can hurt database index performance. More on that later.
UUID v1 (Time-based with MAC Address)
Version 1 combines timestamp with your machine's MAC address:
import { v1 as uuidv1 } from 'uuid';
const transactionId = uuidv1();
// Time component allows chronological sorting
When to use v1:
- Transaction IDs where chronological ordering matters
- Distributed systems where you can guarantee unique MAC addresses per node
- Audit logs where timestamp embedding is valuable
Trade-offs: Exposes your MAC address, which is a privacy concern. If you're running in containers or cloud environments, MAC addresses might not be unique.
UUID v5 (Name-based with SHA-1)
Version 5 generates deterministic UUIDs from a namespace and name:
import { v5 as uuidv5 } from 'uuid';
// Generate consistent UUID for the same email
const NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
const userUuid = uuidv5('user@example.com', NAMESPACE);
// Always produces the same UUID for this email
console.log(userUuid); // Always the same output
When to use v5:
- Generating consistent IDs from external identifiers (emails, URLs)
- Creating idempotent operations
- Mapping external systems without storing the mapping
Trade-offs: Not random—same input always produces the same output. You're responsible for uniqueness by managing namespaces properly.
UUID v7 (Time-ordered)
Version 7 is the newest spec, combining timestamp-ordering with randomness:
import { v7 as uuidv7 } from 'uuid';
const orderedId = uuidv7();
// Timestamp prefix allows efficient database indexing
When to use v7:
- Primary keys in relational databases
- When you need both uniqueness and good index performance
- Distributed systems requiring sortable IDs
Trade-offs: Not supported in older uuid library versions. Requires uuid@9.0.0 or higher.
You'll need to install the uuid package first for any of these approaches:
npm install uuid
npm install @types/uuid --save-dev
The @types/uuid package provides TypeScript types for better type checking and autocompletion.
Generating UUIDs Without Dependencies
If you're in a modern Node.js environment (14.17.0+) or browser, you can use the built-in crypto.randomUUID() method:
function generateUuid(): string {
return crypto.randomUUID();
}
const userId = generateUuid();
console.log(userId); // "a3bb189e-8bf9-3888-9912-ace4e6543002"
This generates v4 UUIDs with no dependencies and better performance than the uuid package. It's cryptographically secure and about 3x faster than uuid.v4() for high-volume generation.
When to use crypto.randomUUID():
- You only need v4 UUIDs
- You want zero dependencies
- Performance matters for high-volume generation
- You're targeting modern runtimes
When to use the uuid package:
- You need v1, v5, or v7 UUIDs
- You need to support older Node.js versions
- You're working in React Native or other non-standard environments
When working with TypeScript function types, you can create strongly-typed UUID generator functions that ensure consistent return values.
For server-side applications using Convex, you can integrate UUID generation directly with your backend services. Check out Convex's TypeScript best practices for more information on implementing this pattern effectively.
UUID Alternatives: When to Use nanoid or ULID
UUIDs aren't your only option for unique identifiers. Depending on your requirements, alternatives might be better:
| Feature | UUID v4 | nanoid | ULID |
|---|---|---|---|
| Size | 36 characters | 21 characters (customizable) | 26 characters |
| Format | 8-4-4-4-12 with hyphens | URL-safe alphabet | Base32 encoded |
| Sortable | No | No | Yes (timestamp-ordered) |
| Collision Risk | ~1 in billion at 103 trillion IDs | Similar to UUID | Similar to UUID |
| Performance | Fast | 2x slower than crypto.randomUUID | Fastest for sorted IDs |
| Database Index | Poor (random) | Poor (random) | Excellent (sequential) |
When to Choose nanoid
import { nanoid } from 'nanoid';
const shortId = nanoid(); // "V1StGXR8_Z5jdHi6B-myT"
const customLength = nanoid(10); // "IRFa-VaY2b"
Use nanoid when:
- You need URL-friendly IDs (no special characters)
- Bundle size matters (4x smaller than uuid)
- You want readable IDs in URLs or file names
- Custom ID length is important
When to Choose ULID
import { ulid } from 'ulid';
const sortableId = ulid(); // "01ARZ3NDEKTSV4RRFFQ69G5FAV"
Use ULID when:
- Database query performance is critical
- You need chronologically sortable IDs
- You want better index locality than UUID v4
- You're okay with timestamp leakage (first 48 bits are timestamp)
Stick with UUID when:
- You need industry-standard identifiers
- Wide database support matters (many DBs have native UUID types)
- You need deterministic generation (v5)
- Regulatory compliance requires standard formats
Validating UUIDs
You'll often receive UUIDs from external sources—API requests, user input, or third-party services. Always validate them before use:
function isValidUuid(uuid: string): boolean {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return uuidRegex.test(uuid);
}
// Validate before using
const incomingId = req.params.id;
if (!isValidUuid(incomingId)) {
throw new Error('Invalid UUID format');
}
For more robust validation, use the uuid package's built-in validator:
import { validate, version } from 'uuid';
const uuid = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d';
if (validate(uuid)) {
console.log(`Valid UUID with version ${version(uuid)}`);
} else {
console.log('Invalid UUID');
}
The validate function is more thorough than regex—it checks format, validates the variant bits, and ensures proper structure.
Type Guards for Runtime Safety
Create type guards to validate UUID strings at runtime:
function isUUID(value: unknown): value is string {
if (typeof value !== 'string') return false;
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return uuidPattern.test(value);
}
// Usage in API handlers
function getUser(id: unknown) {
if (!isUUID(id)) {
throw new Error('Invalid user ID format');
}
// TypeScript now knows id is a string
return database.users.findById(id);
}
For production applications using Convex, review their best practices documentation for guidance on handling unique identifiers in your backend systems.
Type-Safe UUID Handling
TypeScript offers multiple approaches for typing UUIDs, from simple strings to branded types that prevent accidental misuse.
Basic UUID Types
The simplest approach uses a type alias:
type UUID = string;
interface User {
id: UUID;
name: string;
email: string;
}
const user: User = {
id: crypto.randomUUID(),
name: 'Jane Doe',
email: 'jane@example.com'
};
This documents intent but provides no compile-time safety—TypeScript still treats it as a plain string.
Branded Types for Compile-Time Safety
For stricter type checking, use branded types to prevent accidental string assignments:
type UUID = string & { readonly __brand: unique symbol };
function createUUID(value: string): UUID {
if (!isValidUuid(value)) {
throw new Error('Invalid UUID format');
}
return value as UUID;
}
// Type-safe function
function getUser(id: UUID): User | null {
return database.users.get(id);
}
// This causes a type error
// const userId: UUID = "regular-string"; // Type error
// Correct usage
const validUUID = createUUID(crypto.randomUUID());
const user = getUser(validUUID); // Works
This approach leverages TypeScript utility types to create nominal types that prevent accidental assignment of plain strings to UUID variables.
Integration with Generics
Combine UUID types with TypeScript generics for reusable components:
interface Identifiable<T extends string = UUID> {
id: T;
}
class Repository<T extends Identifiable> {
private items: Map<T['id'], T> = new Map();
save(item: T): void {
this.items.set(item.id, item);
}
findById(id: T['id']): T | undefined {
return this.items.get(id);
}
delete(id: T['id']): boolean {
return this.items.delete(id);
}
}
// Usage
interface User extends Identifiable {
name: string;
email: string;
}
const userRepo = new Repository<User>();
For more advanced type manipulations with UUIDs, explore TypeScript utility types. When working with backend systems, review Convex's TypeScript best practices for typing strategies in production applications.
Database Performance Considerations
Here's where UUID choice significantly impacts your application: database indexing performance.
The Problem with Random UUIDs (v4)
Random UUIDs kill database performance at scale. Here's why:
// These v4 UUIDs have no correlation to insert order
const user1 = { id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', name: 'Alice' };
const user2 = { id: '2c5ea4c0-4067-11e9-8bad-9b1deb4d3b7d', name: 'Bob' };
const user3 = { id: '7c9e6679-7425-40de-944b-e07fc1f90ae7', name: 'Carol' };
// B-tree index has to scatter these across different pages
// Result: poor cache locality, more disk I/O, slower queries
When you insert records with random UUIDs as primary keys, your database's B-tree index has to place them all over the tree structure. This means:
- Index fragmentation: New rows aren't clustered near recently inserted rows
- Poor cache hit ratio: The whole index must stay in memory for good performance
- Slower inserts: Each insert might require reading a different index page from disk
For PostgreSQL specifically, once your index size exceeds shared_buffers, cache hit ratio deteriorates quickly with random UUIDs.
Use UUID v7 or ULID for Database Keys
Time-ordered UUIDs solve this problem:
import { v7 as uuidv7 } from 'uuid';
// These maintain insert order while staying unique
const user1 = { id: uuidv7(), name: 'Alice' }; // Starts with timestamp
const user2 = { id: uuidv7(), name: 'Bob' }; // Slightly larger timestamp
const user3 = { id: uuidv7(), name: 'Carol' }; // Even larger timestamp
// B-tree index keeps these on nearby pages
// Result: better cache locality, fewer disk I/O operations
Performance impact:
- UUID v7/ULID indexes behave more like sequential integer indexes
- Better cache hit ratios as recent inserts stay "hot"
- Reduced index bloat and fragmentation
- Faster range queries and inserts
Practical Recommendations
// Avoid for database primary keys
const userId = crypto.randomUUID(); // Random v4
// Better for database primary keys
import { v7 as uuidv7 } from 'uuid';
const userId = uuidv7(); // Time-ordered v7
// Alternative with better DB performance
import { ulid } from 'ulid';
const userId = ulid(); // Lexicographically sortable
For MongoDB: Regular B-tree indexes work fine with UUIDs. Just ensure you're indexing the field:
// Create index on UUID field
db.users.createIndex({ id: 1 });
For PostgreSQL: Use the native uuid data type and consider UUID v7:
CREATE TABLE users (
id UUID PRIMARY KEY,
name TEXT NOT NULL
);
CREATE INDEX idx_users_id ON users(id);
The takeaway: if you're using UUIDs as primary keys in a relational database, strongly consider UUID v7 or ULID over random v4 UUIDs. The performance difference becomes significant as your database grows.
Using UUIDs with Convex
When integrating UUIDs with Convex, generate them client-side and pass them to your backend mutations:
Client-Side UUID Generation
import { v4 as uuidv4 } from 'uuid';
import { useMutation } from 'convex/react';
import { api } from '../convex/_generated/api';
function CreateUser() {
const createUser = useMutation(api.users.create);
const handleCreate = async (name: string, email: string) => {
const userId = uuidv4();
await createUser({ id: userId, name, email });
};
return /* UI component here */;
}
Backend Validation and Storage
Implement server-side validation in your Convex mutations:
// convex/users.ts
import { v } from 'convex/values';
import { mutation, query } from './_generated/server';
// UUID validation helper
function validateUuid(uuid: string): boolean {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return uuidRegex.test(uuid);
}
export const create = mutation({
args: {
id: v.string(),
name: v.string(),
email: v.string(),
},
handler: async (ctx, args) => {
if (!validateUuid(args.id)) {
throw new Error('Invalid UUID format');
}
// Check for existing ID
const existing = await ctx.db.query('users')
.filter(q => q.eq(q.field('id'), args.id))
.first();
if (existing) {
throw new Error('UUID already exists');
}
return ctx.db.insert('users', {
id: args.id,
name: args.name,
email: args.email,
createdAt: Date.now(),
});
},
});
export const getById = query({
args: { id: v.string() },
handler: async (ctx, args) => {
return ctx.db.query('users')
.filter(q => q.eq(q.field('id'), args.id))
.first();
},
});
For optimal performance with UUID lookups, consider adding database indexes. Check Convex's documentation on modules for indexing strategies.
Shared Types for Consistency
Define shared types across your application:
// types.ts
export type UUID = string;
export interface BaseEntity {
id: UUID;
createdAt: number;
updatedAt?: number;
}
export interface User extends BaseEntity {
name: string;
email: string;
}
When implementing authentication or complex queries with UUIDs, refer to Convex's TypeScript best practices for production-ready patterns.
Comparing UUIDs
UUID comparison is straightforward, but there are nuances to handle:
function compareUUIDs(uuid1: string, uuid2: string): boolean {
// Case-insensitive comparison (UUIDs are case-insensitive per spec)
return uuid1.toLowerCase() === uuid2.toLowerCase();
}
// Batch operations
function findDuplicates(uuids: string[]): string[] {
const seen = new Set<string>();
const duplicates = new Set<string>();
for (const uuid of uuids) {
const normalized = uuid.toLowerCase();
if (seen.has(normalized)) {
duplicates.add(normalized);
}
seen.add(normalized);
}
return Array.from(duplicates);
}
When using TypeScript type assertion with branded types, comparisons become type-safe:
type UUID = string & { readonly __brand: unique symbol };
function assertUUID(value: string): asserts value is UUID {
if (!validateUuid(value)) {
throw new Error('Invalid UUID format');
}
}
function safeCompare(uuid1: string, uuid2: string): boolean {
assertUUID(uuid1);
assertUUID(uuid2);
return uuid1.toLowerCase() === uuid2.toLowerCase();
}
For applications using Convex, implement these validation checks in your mutations before database operations. Review Convex's other recommendations for data validation patterns.
Building a Custom UUID Generator
While the uuid package is recommended for production, understanding how UUID generation works can be valuable:
function generateSimpleUUID(): string {
// Version 4 UUID template
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
This uses Math.random(), which isn't cryptographically secure. For production, always use crypto.randomUUID() or the uuid package.
Why you shouldn't roll your own in production:
Math.random()is predictable and can be seeded- Subtle bugs in bit manipulation can create collisions
- Established libraries are battle-tested across billions of generations
- Standards compliance matters for interoperability
When implementing custom generators with TypeScript function types, ensure proper validation and error handling. But really, just use crypto.randomUUID() or the uuid package.
Key Takeaways
Here's what matters when working with UUIDs in TypeScript:
- Use UUID v4 for general-purpose unique identifiers where ordering doesn't matter
- Choose UUID v7 or ULID for database primary keys to avoid index fragmentation
- Use UUID v5 when you need deterministic, reproducible IDs from consistent inputs
- Prefer
crypto.randomUUID()over the uuid package if you only need v4 UUIDs - Consider nanoid for shorter, URL-friendly identifiers with smaller bundle size
- Always validate UUIDs from external sources before using them
- Use branded types to prevent accidentally passing plain strings where UUIDs are expected
- Random UUIDs (v4) hurt database performance—time-ordered variants solve this
For comprehensive TypeScript patterns, explore TypeScript generics and utility types to enhance your UUID implementations. When building with Convex, refer to their TypeScript best practices for production-ready solutions.