类型安全 "in" 类型保护

Type-safe "in" type guard

我最近修复了一个错误,我们有这样的代码:

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

在有人不小心从 IFirst 的定义中删除了 assumedUnique 之前,这一切都很好(您可以通过将 assumedUnique 添加到其他接口之一来解决类似的问题)。

从那时起,我们开始总是沿着第二条代码路径走下去,没有人注意到,编译器也没有强制要求 属性 名称是有意义的。

为了让它更好,我做了这个功能:

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

这个函数有两个目标:

  1. 要求给定的 属性 名称是存在于对象上的 属性
  2. 要求给定的 属性 名称可以将对象唯一标识为给定类型

使用上面的实现,我可以将上面的代码重写为:

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

现在,如果有人更改接口,将导致编译器错误。

我们现在普遍使用这个函数,包括像这样的长链:

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

有没有一种方法可以编写不需要我指定第二个类型参数的泛型函数?

一个成功的实现应该能够捕获以下场景,需要指定一个或更少的 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);
        }
    });
});

我试过的一些东西(加上这些的一些变体,我没有包括因为它们非常 similar/I 记不太清了),none 其中编译:

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

所以你可能想要一个像这样工作的safeIn()

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

这里,obj是一些对象类型T,可能是union,而key是键类型K。重要的部分是:

  • KconstrainedUniqueKeys<T>,它们是已知恰好出现在 T 并集的一个成员中的键;和

  • return 类型是 type predicate obj is FilterByKnownKey<T, K>,其中 FilterByKnownKey<T, K>T 联合过滤为那些已知的成员有钥匙 K.

这意味着我们需要实现 UniqueKeys<T>FilterByKnownKey<T, K>。如果我们能做到这一点,那么您在调用 safeIn() 时就不需要手动指定任何类型参数; KT 将被推断为所提供的 keyobj 参数的明显类型。


所以让我们实施它们。首先,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;

这里我们使用 distributive conditional types 在处理它们之前将联合拆分为它们的成员。

例如AllKeys<T>就是T extends unknown ? keyof T : never;这可能看起来与 keyof T 相同,但事实并非如此。如果 T{a: 0} | {b: 0},则 AllKeys<T> 计算为 keyof {a: 0} | keyof {b: 0},即 "a" | "b",而 keyof T 本身将计算为 never(因为联合没有重叠键,所以 "a""b" 都不是 T 的键)。因此,AllKeys<T> 采用联合类型 T 并且 return 是存在于任何联合成员中的所有键的联合。

对于您的 PossibleProperties 类型,计算结果为:

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

现在 UniqueKeys<T> 更进一步。首先,我们需要保留完整的联合 T,同时将其拆分为多个成员。我使用 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 来表示非 T 的所有其他联盟成员。因此 Exclude<keyof T, AllKeys<Exclude U, T>> 是来自 T 的成员的所有键,它们不是联合 U 的任何其他成员的键。也就是说,它只是 T 独有的键。因此整个表达式变成了所有键的联合,这些键对于联合中的某个成员是唯一的。

对于您的 PossibleProperties 类型,计算结果为:

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

这样就成功了。


现在 FilterByKnownKey<T, K>:

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

这是另一种分配条件类型。我们将联盟拆分为成员 T,并且对于每个成员,当且仅当 Kkeyof T 中时,我们将其包括在内。对于您的 PossibleProperties 类型和 "actualUnique2",计算结果为:

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

这也奏效了。


让我们确保它在您的测试用例中如您所愿地工作。

declare const obj: PossibleProperties;    

当我们用 safeIn() 检查类型 PossibleProperties 的值时,我们可以使用键 "actualUnique1",因为它是只有 IFirst 的已知键。但是我们不能使用 "assumedUnique""fakeProperty",因为它们是 PossibileProperties:

中太多或太少成员的已知密钥
safeIn("actualUnique1", obj); // okay
safeIn("assumedUnique", obj); // error
safeIn("fakeProperty", obj); // error

由于 safeIn() 在其 obj 输入上充当类型保护,您可以使用 if/else 语句连续缩小 [=20 的明显类型=].因此 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

我们可以依次消去"actualUnique1""actualUnique2""actualUnique3"。如果你消除了 "actualUnique2" 那么再次测试 "actualUnique2" 是错误的,但是测试 "assumedUnique" 没问题,因为现在它只存在于 IFirst 上(因为 ISecond已被淘汰)。


所以这一切看起来都不错。总是有警告,因此鼓励任何使用此类东西的人首先针对他们的用例和可能的边缘情况进行测试。

Playground link to code