Skip to content

Instantly share code, notes, and snippets.

@sandcastle
Created March 12, 2025 22:30
Show Gist options
  • Select an option

  • Save sandcastle/bbf348799a99154c7dbdcd3287e64121 to your computer and use it in GitHub Desktop.

Select an option

Save sandcastle/bbf348799a99154c7dbdcd3287e64121 to your computer and use it in GitHub Desktop.
Typescript Guid v7 Implementation
/// <reference types="jest" />
import { Guid } from './Guid';
// Jest globals are likely configured in the project's tsconfig or type definitions
describe('Guid', () => {
describe('constructor', () => {
it('should create a valid GUID from a string', () => {
const guidStr = '123e4567-e89b-7123-8456-426614174000';
const guid = new Guid(guidStr);
expect(guid.valueOf()).toBe(guidStr);
});
it('should create a valid GUID from another GUID instance', () => {
const guidStr = '123e4567-e89b-7123-8456-426614174000';
const guid1 = new Guid(guidStr);
const guid2 = new Guid(guid1);
expect(guid2.valueOf()).toBe(guidStr);
});
it('should handle GUID with curly braces', () => {
const guidStr = '{123e4567-e89b-7123-8456-426614174000}';
const guid = new Guid(guidStr);
expect(guid.valueOf()).toBe('123e4567-e89b-7123-8456-426614174000');
});
it('should handle GUID without hyphens', () => {
const guidStr = '123e4567e89b712384564266141740ab';
const guid = new Guid(guidStr);
expect(guid.valueOf()).toBe(guidStr);
});
it('should handle empty GUID', () => {
const guid = new Guid('00000000-0000-0000-0000-000000000000');
expect(guid.isEmpty()).toBe(true);
});
it('should throw error for invalid GUID', () => {
expect(() => new Guid('not-a-guid')).toThrow('Invalid GUID');
expect(() => new Guid('123e4567-e89b-8123-8456-426614174000')).toThrow(
'Invalid GUID'
); // Wrong version (8)
});
// New tests for error cases
it('should throw specific errors for different invalid inputs', () => {
// Too short GUID
expect(() => new Guid('123e4567-e89b-7123')).toThrow('Invalid GUID');
// Invalid characters
expect(() => new Guid('123e4567-e89b-7123-8456-42661417400z')).toThrow(
'Invalid GUID'
);
// Null input
expect(() => new Guid(null as any)).toThrow();
// Undefined input
expect(() => new Guid(undefined as any)).toThrow();
});
});
describe('formatting methods', () => {
const guidStr = '123e4567-e89b-7123-8456-426614174000';
let guid: Guid;
beforeEach(() => {
guid = new Guid(guidStr);
});
it('should get raw value without hyphens', () => {
expect(guid.raw()).toBe('123e4567e89b71238456426614174000');
});
it('should convert to default format (d)', () => {
expect(guid.toString()).toBe(guidStr);
expect(guid.toString('d')).toBe(guidStr);
});
it('should convert to format without hyphens (n)', () => {
expect(guid.toString('n')).toBe('123e4567e89b71238456426614174000');
});
it('should convert to format with braces (b)', () => {
expect(guid.toString('b')).toBe(`{${guidStr}}`);
});
it('should handle non-standard format', () => {
expect(guid.toString('x')).toBe(guidStr); // Default to 'd' format
});
});
describe('utility methods', () => {
it('should check if GUID is empty', () => {
const emptyGuid = Guid.getEmpty();
expect(emptyGuid.isEmpty()).toBe(true);
const nonEmptyGuid = new Guid('123e4567-e89b-7123-8456-426614174000');
expect(nonEmptyGuid.isEmpty()).toBe(false);
});
it('should compare GUIDs correctly', () => {
const guid1 = new Guid('123e4567-e89b-7123-8456-426614174000');
const guid2 = new Guid('123e4567-e89b-7123-8456-426614174000');
const guid3 = new Guid('223e4567-e89b-7123-8456-426614174000');
expect(guid1.equals(guid2)).toBe(true);
expect(guid1.equals(guid3)).toBe(false);
expect(guid1.equals('123e4567-e89b-7123-8456-426614174000')).toBe(true);
expect(guid1.equals('{123e4567-e89b-7123-8456-426614174000}')).toBe(true);
expect(guid1.equals(null)).toBe(false);
expect(guid1.equals(undefined)).toBe(false);
expect(guid1.equals('invalid-guid')).toBe(false);
});
it('should convert to JSON properly', () => {
const guidStr = '123e4567-e89b-7123-8456-426614174000';
const guid = new Guid(guidStr);
expect(guid.toJSON()).toBe(guidStr);
// Test JSON.stringify behavior
const obj = { id: guid };
expect(JSON.stringify(obj)).toBe(`{"id":"${guidStr}"}`);
});
it('should shorten GUID to Base58', () => {
const guid = new Guid('123e4567-e89b-7123-8456-426614174000');
const shortened = guid.shorten();
// Base58 output should be shorter than the original UUID
expect(shortened.length).toBeLessThan(36);
// Should only contain characters from the Base58 alphabet
expect(shortened).toMatch(
/^[123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ]+$/
);
});
it('should produce consistent Base58 shortening', () => {
const guid = new Guid('123e4567-e89b-7123-8456-426614174000');
const shortened1 = guid.shorten();
const shortened2 = guid.shorten();
expect(shortened1).toBe(shortened2);
// Different GUIDs should produce different shortenings
const guid2 = new Guid('223e4567-e89b-7123-8456-426614174000');
const shortened3 = guid2.shorten();
expect(shortened1).not.toBe(shortened3);
});
// New test: Testing hex encoding edge cases
it('should handle zero-byte edge cases in Base58 conversion', () => {
// GUID with leading zeros
const guid = new Guid('00000000-e89b-7123-8456-426614174000');
const shortened = guid.shorten();
// Base58 should preserve leading zeros in some form
expect(shortened).toBeTruthy();
expect(shortened.length).toBeGreaterThan(0);
});
// New test: Equality with different GUID formats
it('should handle equality with different formats', () => {
const guid = new Guid('123e4567-e89b-7123-8456-426614174000');
// Different formats of same GUID should be equal
expect(guid.equals('123e4567e89b712384564266141740')).toBe(false); // Different actual value
expect(guid.equals('{123e4567-e89b-7123-8456-426614174000}')).toBe(true);
// The current implementation is case-sensitive
expect(guid.equals('123E4567-E89B-7123-8456-426614174000')).toBe(false);
});
});
describe('generateV7', () => {
it('should generate valid v7 UUIDs', () => {
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
for (let i = 0; i < 10; i++) {
const uuid = Guid.generateV7();
expect(uuid).toMatch(uuidRegex);
// Test that the constructor accepts the generated UUID
const guid = new Guid(uuid);
expect(guid.valueOf()).toBe(uuid);
}
});
it('should create UUIDs with increasing timestamp parts', () => {
// We'll need to mock Date.now() for this test to be reliable
const originalDateNow = Date.now;
try {
// Generate UUIDs with increasing timestamps
const timestamps = [1000, 2000, 3000];
const uuids: string[] = [];
for (let i = 0; i < timestamps.length; i++) {
jest.spyOn(Date, 'now').mockReturnValue(timestamps[i]);
uuids.push(Guid.generateV7());
}
// Convert UUIDs to their timestamp components (first 6 bytes)
const extractTimestamp = (uuid: string): number => {
const hex = uuid.replace(/-/g, '');
const timestampHex = hex.substring(0, 12); // First 6 bytes (48 bits)
return parseInt(timestampHex, 16);
};
const extractedTimestamps = uuids.map(extractTimestamp);
// Verify timestamps are in ascending order
for (let i = 1; i < extractedTimestamps.length; i++) {
expect(extractedTimestamps[i]).toBeGreaterThan(
extractedTimestamps[i - 1]
);
}
} finally {
// Restore original Date.now
jest.spyOn(Date, 'now').mockRestore();
Date.now = originalDateNow;
}
});
it('should set correct version and variant bits', () => {
const uuid = Guid.generateV7();
// Parse the UUID components
const parts = uuid.split('-');
// Check version (should be 7)
const version = parts[2].charAt(0);
expect(version).toBe('7');
// Check variant (should be 8, 9, a, or b)
const variant = parts[3].charAt(0);
expect(variant).toMatch(/[89ab]/i);
});
it('should generate unique UUIDs even when called rapidly', () => {
const uuids = new Set<string>();
const count = 1000;
for (let i = 0; i < count; i++) {
uuids.add(Guid.generateV7());
}
// Every UUID should be unique
expect(uuids.size).toBe(count);
});
// New test: Test monotonicity properties
it('should generate monotonically increasing UUIDs', () => {
const extractTimestamp = (uuid: string): number => {
const hex = uuid.replace(/-/g, '');
const timestampHex = hex.substring(0, 12); // First 6 bytes (48 bits)
return parseInt(timestampHex, 16);
};
// Generate a sequence of UUIDs
const uuids: string[] = [];
for (let i = 0; i < 10; i++) {
uuids.push(Guid.generateV7());
}
// Extract timestamps
const timestamps = uuids.map(extractTimestamp);
// Check if timestamps are monotonically non-decreasing
for (let i = 1; i < timestamps.length; i++) {
expect(timestamps[i]).toBeGreaterThanOrEqual(timestamps[i - 1]);
}
});
});
});
// v7 uuid
export class Guid {
private value: string;
// Flickr-style Base58 alphabet (removed confusable characters: 0, O, I, l)
private static readonly BASE58_ALPHABET =
'123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ';
// UUID v7 format: with and without curly braces
private static readonly GUID_FORMAT =
/^(?:{?([0-9a-f]{8}-?[0-9a-f]{4}-?7[0-9a-f]{3}-?[89ab][0-9a-f]{3}-?[0-9a-f]{12})}?)$/i;
// Standard UUID format for empty GUID
private static readonly EMPTY_GUID = '00000000-0000-0000-0000-000000000000';
public constructor(guid: string | Guid) {
if (guid instanceof Guid) {
this.value = guid.valueOf();
return;
}
// Handle empty GUID case
if (guid === Guid.EMPTY_GUID) {
this.value = Guid.EMPTY_GUID;
return;
}
if (Guid.GUID_FORMAT.test(guid)) {
const result = Guid.GUID_FORMAT.exec(guid);
if (result) {
// Extract the UUID part (without curly braces) and store it
// in the standard format with hyphens
this.value = result[1];
return;
}
}
throw new Error(`Invalid GUID ${guid}`);
}
/**
* Get the raw UUID string with hyphens
*/
public valueOf(): string {
return this.value;
}
/**
* Get the UUID without any formatting (no hyphens)
*/
public raw(): string {
return this.value.replace(/-/g, '');
}
/**
* Returns the JSON version of the GUID.
*/
public toJSON(): string {
return this.value;
}
/**
* Converts the GUID to a formatted string.
*
* Formats:
* n = 00000000000000000000000000000000
* d = 00000000-0000-0000-0000-000000000000 (default)
* b = {00000000-0000-0000-0000-000000000000}
*/
toString(format: string = 'd'): string {
switch (format.toLowerCase()) {
case 'n':
return this.value.replaceAll('-', '');
case 'b':
return `{${this.value}}`;
default:
return this.value;
}
}
/**
* Checks if this GUID is empty (all zeros)
* @returns true if the GUID is empty, false otherwise
*/
public isEmpty(): boolean {
return this.value === Guid.EMPTY_GUID;
}
/**
* Returns an empty GUID instance
* @returns A new Guid instance representing an empty GUID
*/
public static getEmpty(): Guid {
return new Guid(Guid.EMPTY_GUID);
}
/**
* Convert Guid to Base58 encoding using Flickr-style alphabet.
* This creates a shorter, URL-friendly ID by removing confusable characters.
*/
public shorten(): string {
const hex = this.value.replace(/-/g, '');
return Guid.hexToBase58(hex);
}
/**
* Compares if the specified GUID is equal to the current GUID.
* @param {String|Guid|undefined|null} value The value to compare.
* @returns True if equal, else false.
*/
equals(value: string | Guid | undefined | null): boolean {
try {
if (!value) {
return false;
}
if (!this.value) {
return false;
}
return this.value === new Guid(value).valueOf();
} catch {
return false;
}
}
/**
* Convert a hexadecimal string to Base58 encoding
*/
private static hexToBase58(hex: string): string {
// Convert hex to a big integer
let num = BigInt('0x' + hex);
let result = '';
const base = BigInt(this.BASE58_ALPHABET.length);
// Convert big integer to Base58
while (num > 0) {
const remainder = Number(num % base);
result = this.BASE58_ALPHABET[remainder] + result;
num = num / base;
}
// Add leading '1's for leading zeros in hex (preserving padding)
for (let i = 0; i < hex.length; i += 2) {
if (hex.substring(i, i + 2) !== '00') {
break;
}
result = this.BASE58_ALPHABET[0] + result;
}
return result;
}
static generateV7(): string {
// Get current timestamp in milliseconds (48 bits)
const timestamp = BigInt(Date.now());
// Create a buffer for the UUID bytes
const uuidBytes = new Uint8Array(16);
// Set the timestamp in the first 48 bits (6 bytes)
uuidBytes[0] = Number((timestamp >> BigInt(40)) & BigInt(0xff));
uuidBytes[1] = Number((timestamp >> BigInt(32)) & BigInt(0xff));
uuidBytes[2] = Number((timestamp >> BigInt(24)) & BigInt(0xff));
uuidBytes[3] = Number((timestamp >> BigInt(16)) & BigInt(0xff));
uuidBytes[4] = Number((timestamp >> BigInt(8)) & BigInt(0xff));
uuidBytes[5] = Number(timestamp & BigInt(0xff));
// Generate random bytes for the rest of the UUID
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
// Fill bytes 6-15 with random values
crypto.getRandomValues(uuidBytes.subarray(6));
} else {
// Fallback to Math.random
for (let i = 6; i < 16; i++) {
uuidBytes[i] = Math.floor(Math.random() * 256);
}
}
// Set version to 7 in the most significant 4 bits of byte 6
uuidBytes[6] = (uuidBytes[6] & 0x0f) | 0x70;
// Set the variant to RFC4122 (bits: 10xx) in the most significant 2 bits of byte 8
uuidBytes[8] = (uuidBytes[8] & 0x3f) | 0x80;
// Convert to hex string with proper formatting
const hex = Array.from(uuidBytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
return [
hex.substring(0, 8),
hex.substring(8, 12),
hex.substring(12, 16),
hex.substring(16, 20),
hex.substring(20, 32),
].join('-');
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment