首页 > 解决方案 > 禁止显式类型参数或约束联合类型参数以提高类型安全性

问题描述

getValues()当在没有显式类型参数的情况下调用函数时,以下 Typescript 代码可以正常工作。但是,当使用显式类型参数调用它时,可能会导致错误(参见getValues<'a' | 'b' | 'c' >(store, 'a', 'b')下面的调用)。

type Store = Record<string, string>;

function getValues<T extends string>(
    store: Store, ...keys: T[]
): {[P in T]: string} {
    const values = {} as {[P in T]: string};

    keys.forEach((p) => {
        const value = store[p];
        if (value) {
            values[p] = value;
        } else {
            values[p] = 'no value';
        }
    });

    return values;
}


// Usage / requirements:    

const store: Store = {}; // assume it is filled with arbitrary values


// in the following line values1 is inferred to be of type
// {a: string, b: string}, which is the required behavior
const values1 = getValues(store, 'a', 'b');


// the following line does not compile, which is the required behavior
// Argument of type 'b' is not assignable to parameter of type 'a' | 'c'
const values2 = getValues<'a' | 'c'>(store, 'a', 'b');


// in the following line values3 is inferred to be of type
// {a: string, b: string, c: string}, which is NOT the required behavior
// the required behavior would be to forbid this situation, similar to
// how values2 declaration was forbidden by the compiler.
const values3 = getValues<'a' | 'b' | 'c' >(store, 'a', 'b');

// now values3.c can be accessed
// values3.c cannot have a value and will cause an error when used

有没有办法阻止调用者提供显式类型参数或任何其他方式来使此代码更加类型安全,而不会使 getValues 函数的返回类型为部分或使其返回类型的属性可选。

我想到了以下想法,我不知道它们在 Typescript 中是否或如何实现:

想法1:防止提供显式类型参数

想法 2:删除 getValues 的值参数并在运行时访问提供的类型参数

想法 3:强制所有提供的类型参数也用作值参数。也就是上面声明 values3 常量会导致编译错误。

我知道问题是由不安全类型断言引起的as {[P in T]: string}。我正在寻找一种使这种类型安全的方法。

标签: typescripttypescript-generics

解决方案


这可能比它的价值更麻烦;不幸的是,试图从类型系统获得此类保证所涉及的复杂程度很高,应该权衡人们手动指定过宽类型参数的可能性。也许您可以只记录函数 ( /** hey, let the compiler infer T, thanks **/) 而不是试图让编译器为您强制执行此操作。


首先,没有办法阻止指定显式类型参数。即使你可以,它也可能对你没有多大帮助,因为类型推断仍然可能导致你试图避免的更广泛的类型:

const aOrB = Math.random() < 0.5 ? "a" : "b";
getValues(store, aOrB); // {a: string, b: string} again

您也不能在运行时访问类型参数,因为它们与静态类型系统的其余部分一样,在转换为 JavaScript 时会被删除。


环顾四周,我认为 TS 人员在尝试处理具有类似问题的计算属性键时遇到了这个问题。提出的解决方案(但未在该语言中实现,请参阅microsoft/TypeScript#21030和链接的问题以供讨论)似乎是调用Unionize的东西,{a: string} | {b: string}如果您不知道密钥是否为"a""b".


我想我可以想象使用一种类型,例如为传入的每个键采用这些类型Unionize编程交集。这意味着您希望泛型参数不是键类型的联合,而是键类型的其余元组

type UnionizeRecord<K, V> = K extends PropertyKey ? { [P in K]: V } : never;
type Expand<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;
type UnionizeRecordTupleKeys<K extends PropertyKey[], V> = Expand<
    { [I in keyof K]: (x: UnionizeRecord<K[I], V>) => void }[number] extends
    ((x: infer R) => void) ? R : never
>;

UnionizeRecord<K,V>类型类似于Record<K, V>但产生一个类型的联合,一个对应于K. 该Expand<T>类型仅有助于 IntelliSense 输出;它将交叉点 like{a: string} & {bar: number}转换为等价的单个对象类型,例如{a: string, bar: number}. 而且UnionizeRecordTupleKeys<K, V>,嗯,很丑。它所做的是遍历元组I的每个索引K并生成一个UnionizeRecord<K[I], V>. 然后它将它们相交并扩展它们。通过分布式条件类型和条件类型的推断来解释所有工作方式的细节。

然后你的getValues()函数看起来像这样:

function getValues<K extends (number extends K['length'] ? [string] : string[])>(
    store: Store, ...keys: [...K]
): UnionizeRecordTupleKeys<K, string> {
    const values = {} as any; // not worried about impl safety here

    keys.forEach((p) => {
        const value = store[p];
        if (value) {
            values[p] = value;
        } else {
            values[p] = 'no value';
        }
    });

    return values;
}

我不想让编译器理解它可以将事物分配给未指定的泛型类型参数UnionizeRecordTupleKeys<K, string>时的类型值,因此我在实现中使用了它。另一个问题是我将类型限制为可分配给的元组;我想防止像. 实际上没有必要阻止这种情况,因为输出类型会比你喜欢的更宽,但我想阻止有人反对手动指定一些奇怪的东西。您可以使用类似的技巧来防止其他特定的事情。KanyKstring[]Array<"a" | "b">


让我们看看它是如何工作的。这仍然符合您的要求:

const values1 = getValues(store, 'a', 'b');
/*const values1: {
    a: string;
    b: string;
} */

如果你手动指定一个类型参数,它需要是一个元组:

const values2 = getValues<["a", "b"]>(store, 'a', 'b');
/* const values2: {
    a: string;
    b: string;
} */

如果你指定了错误的元组,你会得到一个错误:

const values3 = getValues<["a", "b", "c"]>(store, 'a', 'b'); // error!
// -----------> ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Expected 4 arguments, but got 3.

如果为其中一个元组元素指定或传入联合,则会得到对象类型的联合:

const aOrB = Math.random() < 0.5 ? "a" : "b";
const values4 = getValues(store, aOrB); 
/* const values4: {
    a: string;
} | {
    b: string;
} */

这可以成为很多事情的有趣结合:

const cOrD = Math.random() < 0.5 ? "c" : "d";
const values5 = getValues(store, aOrB, cOrD); 
/* const values5: {
    a: string;
    c: string;
} | {
    a: string;
    d: string;
} | {
    b: string;
    c: string;
} | {
    b: string;
    d: string;
} */

所以一切正常!不过,可能有很多我没有考虑过的边缘情况......即使它有效,它也很笨重和复杂。再说一次,我可能只是建议记录您的原始函数,以便调用者了解编译器的局限性。但是很高兴看到您与“正确”输出类型有多接近!

Playground 代码链接


推荐阅读