Created
March 12, 2025 22:30
-
-
Save sandcastle/bbf348799a99154c7dbdcd3287e64121 to your computer and use it in GitHub Desktop.
Typescript Guid v7 Implementation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /// <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]); | |
| } | |
| }); | |
| }); | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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