typescripttypestypeguards

Type-safe "in" type guard


I recently fixed a bug where we had code like this:

interface IBase { 
    sharedPropertyName: string; 
}
interface IFirst extends IBase { 
    assumedUnique: string;  
    unique1: string; 
}
interface ISecond extends IBase {  
    unique2: string; 
}
interface IThird extends IBase {  
    unique3: string; 
}

type PossibleInputs = IFirst | ISecond | IThird;
    
function getSharedProperty(obj: PossibleInputs): string | undefined {
    if ("assumedUnique" in obj) {
        return "version 1 of the function";
    }

    // do some operation using obj.sharedPropertyName
    return obj.sharedPropertyName;
}

This worked fine until someone accidentally removed assumedUnique from the definition of IFirst (you can hit a similar issue by adding assumedUnique to one of the other interfaces).

From that point, we started always going down the second code path, and no one noticed nor did the compiler enforce that the property name was meaningful.

To make this better, I made this function:

function safeIn<TExpected, TRest>(
    key: string & Exclude<keyof TExpected, keyof TRest>,
    obj: TExpected | TRest
): obj is TExpected {
    return (key as string) in obj;
}

This function has two goals:

  1. Require that the given property name is a property that exists on the object
  2. Require that the given property name can uniquely identify the object as a given type

Using the above implementation, I was able to rewrite the above as:

function getSharedProperty(obj: PossibleInputs): string | undefined {
    if (safeIn<IFirst, Exclude<PossibleInputs, IFirst>>("assumedUnique", obj)) {
        return "version 1 of the function";
    }

    // do some operation using obj.sharedPropertyName
    return obj.sharedPropertyName;
}

Now, if someone changes the interfaces it'll cause a compiler error.

We use this function generically now, including in long chains like this:

if (safeIn<PossibleType, FullUnionType>("propName", obj)) {
    // do something
} else if (safeIn<NextPossibleType, Exclude<typeof obj, NextPossibleType>>("nextPropName", obj)) {
    // do something
} else if (safeIn<NextNextPossibleType, Exclude<typeof obj, NextNextPossibleType>>("nextNextPropName", obj)) {
    // do something
}
// etc

Is there a way to write a generic function that doesn't require me to specify the second type parameter?

A successful implementation should be able to catch the following scenarios, with one or fewer type parameters to safeIn needing to be specified.

describe("safeIn", () => {
    interface IBase {
        sharedProperty: string;
    }
    interface IFirst extends IBase {
        assumedUnique: string;
        actualUnique1: string;
    }
    interface ISecond extends IBase {
        assumedUnique: string;
        actualUnique2: string;
    }
    interface IThird extends IBase {
        actualUnique3: string;
    }
    type PossibleProperties = IFirst | ISecond | IThird;
    const maker = (value: string, version: 1 | 2 | 3): PossibleProperties => {
        switch (version) {
            case 1:
                return { sharedProperty: value, assumedUnique: value, actualUnique1: value };
            case 2:
                return { sharedProperty: value, assumedUnique: value, actualUnique2: value };
            case 3:
                return { sharedProperty: value, actualUnique3: value };
        }
    }
    const compilesNever = (_: never): never => { throw "bad data"; };
    const firstObj = maker("whatever1", 1);
    const secondObj = maker("whatever2", 2);
    const thirdObj = maker("whatever3", 3);
    it("handles unique property", () => {
        expect(safeIn("actualUnique1", firstObj)).toBeTruthy();
        expect(safeIn("actualUnique1", secondObj)).toBeFalsy();
        expect(safeIn("actualUnique1", thirdObj)).toBeFalsy();
    });
    it("doesn't compile for non-unique properties", () => {
        expect(safeIn("assumedUnique", firstObj)).toBeTruthy();
        expect(safeIn("assumedUnique", secondObj)).toBeFalsy();
        expect(safeIn("assumedUnique", thirdObj)).toBeFalsy();
    });
    it("doesn't compile for missing properties", () => {
        expect(safeIn("fakeProperty", firstObj)).toBeTruthy();
        expect(safeIn("fakeProperty", secondObj)).toBeFalsy();
        expect(safeIn("fakeProperty", thirdObj)).toBeFalsy();
    });
    it("compiles type chain", () => {
        if (safeIn("actualUnique1", thirdObj)) {
            expect(false).toBeTruthy();
        } else if (safeIn("actualUnique2", thirdObj)) {
            expect(false).toBeTruthy();
        } else if (safeIn("actualUnique3", thirdObj)) {
            expect(false).toBeTruthy();
        } else {
            compilesNever(thirdObj);
        }
    });
    it("doesn't compile invalid type chain", () => {
        if (safeIn("actualUnique1", thirdObj)) {
            expect(false).toBeTruthy();
        } else if (safeIn("actualUnique2", thirdObj)) {
            expect(false).toBeTruthy();
        } else if (safeIn("actualUnique2", thirdObj)) {
            expect(false).toBeTruthy();
        } else {
            compilesNever(thirdObj);
        }
    });
});

Some things I've tried (plus some variations of these, which I haven't included because they're very similar/I don't remember them exactly), none of which compile:

type SafeKey<TExpectedObject, TPossibleObject> =
    keyof TExpectedObject extends infer TKey
        ? TKey extends keyof TPossibleObject ? never : string & keyof TExpectedObject
        : never;

function autoSafeIn<TExpected, TRest extends TExpected>(
    obj: TRest | TExpected,
    key: SafeKey<TExpected, typeof obj>): obj is TExpected {
    return (key as string) in obj;
}

function autoSafeIn<TExpected>(
    obj: Record<Exclude<string, keyof TExpected>, unknown> | TExpected,
    key: SafeKey<TExpected, typeof obj>): obj is TExpected {
    return (key as string) in obj;
}

type SafeObject<TExpected> = keyof TExpected extends keyof infer TPossible ? Omit<TExpected, keyof TPossible> : TExpected;

function autoSafeIn<TExpected>(
    obj: SafeObject<TExpected> | object // or any, or unknown,
    key: string & keyof SafeObject<TExpected>
): obj is TExpected {
    return (key as string) in obj;
}

Solution

  • So you probably want a safeIn() that works like this:

    function safeIn<K extends UniqueKeys<T>, T extends object>(
      key: K,
      obj: T
    ): obj is FilterByKnownKey<T, K> {
      return key in obj;
    }
    

    Here, obj is some object type T, which will probably be a union, and key is of key type K. The important pieces are that:

    This means we need to implement UniqueKeys<T> and FilterByKnownKey<T, K>. If we can accomplish this, then you shouldn't need to manually specify any type parameters when you call safeIn(); K and T will be inferred to be the apparent types of the supplied key and obj parameters.


    So let's implement them. First, UniqueKeys<T>:

    type AllKeys<T> =
      T extends unknown ? keyof T : never;
    type UniqueKeys<T, U extends T = T> =
      T extends unknown ? Exclude<keyof T, AllKeys<Exclude<U, T>>> : never;
    

    Here we are using distributive conditional types to split unions up into their members before processing them .

    For example, AllKeys<T> is T extends unknown ? keyof T : never; this might look like the same thing as keyof T, but it isn't. If T is {a: 0} | {b: 0} then AllKeys<T> evaluates to keyof {a: 0} | keyof {b: 0} which is "a" | "b", while keyof T by itself would evaluate to never (since the union has no overlapping keys, so neither "a" nor "b" is known to be a key of T). Thus, AllKeys<T> takes a union type T and returns a union of all keys which exist in any of the union members.

    For your PossibleProperties type, this evaluates to:

    type AllKeysOfPossibleProperties = AllKeys<PossibleProperties>
    // type AllKeysOfPossibleProperties = "assumedUnique" | "actualUnique1" | 
    //   "sharedProperty" | "actualUnique2" | "actualUnique3"
    

    Now UniqueKeys<T> takes this a step further. First, we need to hold onto the full union T while also splitting it up into members. I use a generic parameter default to copy the full T union into another type parameter U. When we write T extends unknown ? ... : never, then T will be the individual members of the union, while U is the full union. Thus Exclude<U, T> uses the Exclude utility type to represent all the other members of the union that are not T. And therefore Exclude<keyof T, AllKeys<Exclude U, T>> is all the keys from the member of T which are not keys of any other members of the union U. That is, it's just the keys which are unique to T. And so the whole expression becomes a union of all the keys which are unique to some member of the union. 😅

    For your PossibleProperties type, this evaluates to:

    type UniqueKeysOfPossibleProperties = UniqueKeys<PossibleProperties>
    // type UniqueKeysOfPossibleProperties = "actualUnique1" | "actualUnique2" |
    //   "actualUnique3"
    

    So that worked.


    Now for FilterByKnownKey<T, K>:

    type FilterByKnownKey<T, K extends PropertyKey> =
      T extends unknown ? K extends keyof T ? T : never : never;
    

    It's another distributive conditional type. We split the union into members T, and for each member, we include it if and only if K is in keyof T. For your PossibleProperties type and "actualUnique2", this evaluates to:

    type PossPropsFilteredByActualUnique2 =
      FilterByKnownKey<PossibleProperties, "actualUnique2">
    // type PossPropsFilteredByActualUnique2 = ISecond
    

    And that worked too.


    Let's make sure it works as you want for your test cases.

    declare const obj: PossibleProperties;    
    

    When we check a value of type PossibleProperties with safeIn(), we can use the key "actualUnique1" since it's a known key of only IFirst. But we can't use "assumedUnique" or "fakeProperty" because they are either a known key of too many or too few of the members of PossibileProperties:

    safeIn("actualUnique1", obj); // okay
    safeIn("assumedUnique", obj); // error
    safeIn("fakeProperty", obj); // error
    

    Since safeIn() acts as a type guard on its obj input, you can use if/else statements to successively narrow the apparent type of obj. And thus UniqueKeys<typeof obj> will themselves change:

    if (safeIn("actualUnique1", obj)) {
    } else if (safeIn("actualUnique2", obj)) {
    } else if (safeIn("actualUnique3", obj)) {
    } else { compilesNever(obj); }
    
    if (safeIn("actualUnique1", obj)) {
    } else if (safeIn("actualUnique2", obj)) {
    } else if (safeIn("actualUnique2", obj)) { // error
    } else { compilesNever(obj); }
    
    if (safeIn("actualUnique2", obj)) {
    } else if (safeIn("assumedUnique", obj)) { } // okay
    

    We can eliminate "actualUnique1", "actualUnique2"and "actualUnique3" in turn. If you eliminate "actualUnique2" then it's an error to test against "actualUnique2" again, but it's fine to test against "assumedUnique", since now it only exists on IFirst (as ISecond has been eliminated).


    So this all looks good. There are always caveats, so anyone using something like this is encouraged to test against their use cases and possible edge cases first.

    Playground link to code