简体   繁体   English

类型安全的“in”类型保护

[英]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).这工作正常,直到有人不小心从assumedUnique的定义中删除了假定IFirst (您可以通过将assumedUnique唯一添加到其他接口之一来解决类似的问题)。

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:

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:这个 function 有两个目标:

  1. Require that the given property name is a property that exists on the object要求给定的属性名称是 object 上存在的属性
  2. Require that the given property name can uniquely identify the object as a given type要求给定的属性名称可以将 object 唯一标识为给定类型

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:我们现在一般使用这个 function,包括像这样的长链:

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?有没有办法编写不需要我指定第二个类型参数的通用 function?

A successful implementation should be able to catch the following scenarios, with one or fewer type parameters to safeIn needing to be specified.一个成功的实现应该能够捕获以下场景,需要指定一个或更少的类型参数到safeIn

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;
}

So you probably want a safeIn() that works like this:所以你可能想要一个这样工作的safeIn()

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 .这里, obj是一些 object 类型T ,它可能是一个union ,而key是键类型K The important pieces are that:重要的部分是:

  • K is constrained to UniqueKeys<T> , which are keys known to appear in exactly one member of the union of T ; K限制UniqueKeys<T> ,这是已知恰好出现在T的并集的一个成员中的键; and

  • the return type is the type predicate obj is FilterByKnownKey<T, K> , where FilterByKnownKey<T, K> filters the T union to just those members known to have key K .返回类型是类型谓词obj is FilterByKnownKey<T, K> ,其中FilterByKnownKey<T, K>T联合过滤到仅那些已知具有键K的成员。

This means we need to implement UniqueKeys<T> and FilterByKnownKey<T, K> .这意味着我们需要实现UniqueKeys<T>FilterByKnownKey<T, K> If we can accomplish this, then you shouldn't need to manually specify any type parameters when you call safeIn() ;如果我们可以做到这一点,那么您在调用safeIn()时不需要手动指定任何类型参数; K and T will be inferred to be the apparent types of the supplied key and obj parameters. KT将被推断为提供的keyobj参数的明显类型。


So let's implement them.所以让我们实现它们。 First, UniqueKeys<T> :首先, 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例如, AllKeys<T>是否T extends unknown? keyof T: never T extends unknown? keyof T: never ; T extends unknown? keyof T: never this might look like the same thing as keyof T , but it isn't.这可能看起来与keyof T相同,但事实并非如此。 If T is {a: 0} | {b: 0}如果T{a: 0} | {b: 0} {a: 0} | {b: 0} then AllKeys<T> evaluates to keyof {a: 0} | keyof {b: 0} {a: 0} | {b: 0}然后AllKeys<T>评估为keyof {a: 0} | keyof {b: 0} keyof {a: 0} | keyof {b: 0} which is "a" | "b" keyof {a: 0} | keyof {b: 0}"a" | "b" "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 ). "a" | "b" ,而keyof T本身将评估为never (因为联合没有重叠的键,所以"a""b"都不知道是T的键)。 Thus, AllKeys<T> takes a union type T and returns a union of all keys which exist in any of the union members.因此, AllKeys<T>采用联合类型T并返回存在于任何联合成员中的所有键的联合。

For your PossibleProperties type, this evaluates to:对于您的PossibleProperties类型,其计算结果为:

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

Now UniqueKeys<T> takes this a step further.现在UniqueKeys<T>更进一步。 First, we need to hold onto the full union T while also splitting it up into members.首先,我们需要保留完整的联合T ,同时将其拆分为成员。 I use a generic parameter default to copy the full T union into another type parameter U .我使用泛型参数默认值将完整的T联合复制到另一个类型参数U中。 When we write T extends unknown? ... : never当我们写T extends unknown? ... : never T extends unknown? ... : never , then T will be the individual members of the union, while U is the full union. T extends unknown? ... : never ,那么T将是工会的个人成员,而U是完整的工会。 Thus Exclude<U, T> uses the Exclude utility type to represent all the other members of the union that are not T .因此Exclude<U, T>使用Exclude实用程序类型来表示联合的所有其他非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 .因此Exclude<keyof T, AllKeys<Exclude U, T>>是来自T的成员的所有键,这些键不是联合U的任何其他成员的键。 That is, it's just the keys which are unique to T .也就是说,它只是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:对于您的PossibleProperties类型,其计算结果为:

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

So that worked.所以这奏效了。


Now for FilterByKnownKey<T, K> :现在为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 .我们将联合拆分为成员T ,并且对于每个成员,当且仅当Kkeyof T中时,我们才包含它。 For your PossibleProperties type and "actualUnique2" , this evaluates to:对于您的PossibleProperties类型和"actualUnique2" ,其计算结果为:

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 .当我们使用safeIn()检查PossibleProperties属性类型的值时,我们可以使用键"actualUnique1" ,因为它是唯一的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 :但是我们不能使用"assumedUnique""fakeProperty" ,因为它们要么是太多或太少的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 .由于safeIn()作为其obj输入的类型保护,您可以使用if / else语句来连续缩小obj的明显类型。 And thus UniqueKeys<typeof obj> will themselves change:因此UniqueKeys<typeof obj>自己会改变:

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.我们可以依次消除"actualUnique1""actualUnique2""actualUnique3" 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).如果您消除了"actualUnique2" ,那么再次针对"actualUnique2"进行测试是错误的,但可以针对"assumedUnique"进行测试,因为现在它只存在于IFirst上(因为ISecond已被消除)。


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 Playground 代码链接

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM