kajacx
kajacx

Reputation: 12949

Typed Guid cannot be used as an index

We a TypeScript app and are thinking about adding a Guid type, like this:

declare type Guid = string & { isGuid: true }

This has benefit that you cannot accidentally assign just any string to Guid, at least without casting it first. But it has the following problem:

const a: Record<Guid, number> = { '3fa85f64-5717-4562-b3fc-2c963f66afa6': 42 };
const b: Guid = '3fa85f64-5717-4562-b3fc-2c963f66afa6';
const c: number = a[b];
// Error: Element implicitly has an 'any' type because expression of type
// 'string & { isGuid: true; }' can't be used to index type 'Record<string & { isGuid: true; }, number>'.

Is there a TypeScript setting to allow this? If I don't declare c's type and hover over it, it correctly dispalys number. Maybe the editor (VS Code) is using a different compiler that the TypeScript executable? Is there a way to fix it?

Upvotes: 1

Views: 210

Answers (1)

John
John

Reputation: 7359

The below code uses TypeScript 4.1.

It's getting late here but feel free to ask me questions and I'll edit the answer later. Just as a suggestion you may want to do the below instead using type guards.

The main type is here:

type GUID<
    s1 extends string = string,
    s2 extends string = string,
    s3 extends string = string,
    s4 extends string = string,
    s5 extends string = string
> = `${s1}-${s2}-${s3}-${s4}-${s5}`

It covers most cases but the cases it doesn't cover need a type guard which I split into three seperate functions:

function guidOctetPart<GP extends string, L extends number>(guidPart: GP, length: L): guidPart is string {
    return guidPart.length === length;
}

function isGuid(guid: string): guid is GUID {
    const [o1,o2,o3,o4,o5] = guid.split("-");
    return ([[o1, 8], [o2, 4], [o3, 4], [o4, 4], [o5, 12]] as const).every(([guidPart, length]) => guidOctetPart(guidPart, length))
}

function isGuidRecord (guid: Record<GUID, number>): guid is Record<GUID, number> {
    return Object.keys(guid).every(isGuid) && Object.values(guid).every(x => typeof x === "number");
}

More details in the playground link and code below... Mainly through the use of comments.

Playground Link (or look at code right below)

//  This could work if this issue gets fixed https://github.com/microsoft/TypeScript/issues/34692
// type GUID<
//     l1 extends number = number,
//     l2 extends number = number,
//     l3 extends number = number,
//     l4 extends number = number,
//     l5 extends number = number, 
//     s1 extends string = string,
//     s2 extends string = string,
//     s3 extends string = string,
//     s4 extends string = string,
//     s5 extends string = string
// > = `${s1 & {length: l1}}-${s2 & {length: l2}}-${s3 & {length: l3}}-${s4 & {length: l4}}-${s5 & {length: l5}}`
// For now we could do this... not as good but close enough.
type GUID<
    s1 extends string = string,
    s2 extends string = string,
    s3 extends string = string,
    s4 extends string = string,
    s5 extends string = string
> = `${s1}-${s2}-${s3}-${s4}-${s5}`

// Guid has too few parts
const Fails1: GUID = "5151515-1161616-171717-1717171" 

// Guid has too many parts 
const Fails2: GUID = "5151515-1161616-171717-1717171-12341414-414141414" 

// proper GUID is allowed
const Passes1: GUID = "12345678-1234-1234-1234-123456789012";

// caveat: Allows wrong lengthed-parts GUIDs
const Passes2: GUID = "1234-5151515-1161616-171717-1717171" 

// TO AVOID the above caveat you should use function type guards

// Show an obviously Failing record
if(isGuidRecord({["1234"]: 1})) {
    console.log("Won't ever log.") // type is always going to be a GUID if it enters here
}

// Proper GUID will log
if(isGuidRecord({[Passes1]: 2})) {
    console.log("Will always log.")
}

// improper guid will not log even though it passes type checks
if(isGuidRecord({[Passes2]: 2})) {
    console.log("Won't ever log.")
}

function guidOctetPart<GP extends string, L extends number>(guidPart: GP, length: L): guidPart is string {
    return guidPart.length === length;
}

function isGuid(guid: string): guid is GUID {
    const [o1,o2,o3,o4,o5] = guid.split("-");
    return ([[o1, 8], [o2, 4], [o3, 4], [o4, 4], [o5, 12]] as const).every(([guidPart, length]) => guidOctetPart(guidPart, length))
}

function isGuidRecord (guid: Record<GUID, number>): guid is Record<GUID, number> {
    return Object.keys(guid).every(isGuid) && Object.values(guid).every(x => typeof x === "number");
}


See the JS Output of the above playground:

"use strict";
// Guid has too few parts
const Fails1 = "5151515-1161616-171717-1717171";
// Guid has too many parts 
const Fails2 = "5151515-1161616-171717-1717171-12341414-414141414";
// proper GUID is allowed
const Passes1 = "12345678-1234-1234-1234-123456789012";
// caveat: Allows wrong lengthed-parts GUIDs
const Passes2 = "1234-5151515-1161616-171717-1717171";
// Show an obviously Failing record
if (isGuidRecord({ ["1234"]: 1 })) {
    console.log("Won't ever log."); // type is always going to be a GUID if it enters here
}
// Proper GUID will log
if (isGuidRecord({ [Passes1]: 2 })) {
    console.log("Will always log.");
}
// improper guid will not log
if (isGuidRecord({ [Passes2]: 2 })) {
    console.log("Won't ever log.");
}
function guidOctetPart(guidPart, length) {
    return guidPart.length === length;
}
function isGuid(guid) {
    const [o1, o2, o3, o4, o5] = guid.split("-");
    return [[o1, 8], [o2, 4], [o3, 4], [o4, 4], [o5, 12]].every(([guidPart, length]) => guidOctetPart(guidPart, length));
}
function isGuidRecord(guid) {
    return Object.keys(guid).every(isGuid) && Object.values(guid).every(x => typeof x === "number");
}

Upvotes: 1

Related Questions