Skip to main content

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:

FeatureUUID v4nanoidULID
Size36 characters21 characters (customizable)26 characters
Format8-4-4-4-12 with hyphensURL-safe alphabetBase32 encoded
SortableNoNoYes (timestamp-ordered)
Collision Risk~1 in billion at 103 trillion IDsSimilar to UUIDSimilar to UUID
PerformanceFast2x slower than crypto.randomUUIDFastest for sorted IDs
Database IndexPoor (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.