打字稿:使相同的对象不可分配给另一个

Typescript: Make identical objects not assignable one to the other

我有一个 StringToString 接口,我到处都在使用它:

interface StringToString {
    [key: string]: string;
}

我也经常交换对象中的键和值。这意味着键成为值,而值成为键。这是函数的类型签名。

function inverse(o: StringToString): StringToString;

问题来了:由于这种交换经常进行,我想知道我正在处理的对象是将键作为值还是将键作为键。

这意味着我想要两种类型:

export interface KeyToValue {
    [key: string]: string;
}
export interface ValueToKey {
    [value: string]: string;
}
type StringToString = KeyToValue | ValueToKey;

有了这些,inverse 函数就变成了:

/** Inverses a given object: keys become values and values become keys. */
export function inverse(obj: KeyToValue): ValueToKey;
export function inverse(obj: ValueToKey): KeyToValue;
export function inverse(obj: StringToString): StringToString {
    // Implementation
}

现在,当我将 ValueToKey 分配给 KeyToValue 时,我希望打字稿能够显示错误。这意味着我希望抛出一个错误:

function foo(obj: KeyToValue) { /* ... */ }

const bar: ValueToKey = { /* ... */ };

foo(bar) // <-- THIS SHOULD throw an error

这可能吗?

这在 TypeScript 中是不可能的。

TypeScript 使用他们所谓的 Structural Typing system (conceptual subset of "Duck Typing"); 它不关心类型的名称是什么,只关心形状。来自手册:

The basic rule for TypeScript’s structural type system is that x is compatible with y if y has at least the same members as x.

你所有的 interface 都有相同的定义(忽略你的索引的名称,这在这个类型系统中同样无关紧要),所以它们在 TypeScript 中都是可以互换的。

品牌可以在这方面提供帮助。这意味着您添加了一个 属性 ,除了类型之外,它从未真正存在过,并使用函数将值转换为具有正确品牌的类型。

通常您可能会使用 _brand: 'Something' 属性,但在这种情况下您希望支持所有字符串键,因此您可以使用 Symbol 代替。

const brand = Symbol('KeysOrValuesBrand')

export interface KeyToValue {
    [key: string]: string;
    [brand]: 'KeyToValue'
}
export interface ValueToKey {
    [value: string]: string;
    [brand]: 'ValueToKey'
}

type StringToString = KeyToValue | ValueToKey

现在一切如你所愿:

declare function foo(obj: KeyToValue): void
declare const bar: ValueToKey
foo(bar) // <-- THIS SHOULD throw an error

const valuesA: ValueToKey = bar // works
const valuesB: ValueToKey = inverse(bar) // error
const valuesC: ValueToKey = inverse(inverse(bar)) // works

缺点是功能必须为您制作这些对象,因为这就是它们获得品牌的方式。例如:

function makeValueToKey(input: Record<string, string>): ValueToKey {
    return input as ValueToKey
}

function makeKeyToValue(input: Record<string, string>): KeyToValue {
    return input as KeyToValue
}

declare function foo(obj: KeyToValue): void
foo({ a: 'b' }) // error
foo(makeValueToKey({ a: 'b' })) // error
foo(makeKeyToValue({ a: 'b' })) // works

这可能有点烦人。

Playground


话虽如此,但我认为字体品牌化是为了隐藏糟糕的数据模型。如果您能让结构类型按预期工作,那通常是更好的途径。

它需要改变你的数据结构,但像这样的事情要简单得多:

export interface KeyToValue {
    type: 'KeyToValue'
    data: Record<string, string>
}
export interface ValueToKey {
    type: 'ValueToKey'
    data: Record<string, string>
}

type StringToString = KeyToValue | ValueToKey

Playground

TypeScript 有一个结构类型系统,这意味着类型是根据它们的内容而不是它们的名称来比较的。即,

export interface KeyToValue {
    [key: string]: string;
}
export interface ValueToKey {
    [value: string]: string;
}

声明相同的类型,因为它们只是命名不同,您可以自由互换:

const x: KeyToValue = {myKey: myValue};
const y: ValueToKey = x;

编译器看到这些类型之间的区别的唯一方法是它们的区别不仅仅是命名,例如通过对键和值使用不同的类型。例如,如果键集在编译类型中是已知的,则可以为键使用文字类型的联合:

type Key = "red" | "green" | "blue";
type Value = string;

然后你可以声明

type KeyToValue = Record<Key, Value>;
type ValueToKey = Record<Value, Key>;

并且编译器可以很好地区分这些类型。

请注意,如果映射固定为编译类型,我们可以使用 keyof 从提供的值轻松派生这些类型:

const colorMap = {
    red: 1,
    green: 2,
    blue: 3,
};

type Key = keyof typeof colorMap; // no need to repeat the possible keys again