如何在TypeScript中键名已知或未知的情况下输入一个对象

50

我正在寻找一种方法来为下面的对象创建TypeScript类型,该对象有两个已知键和一个具有已知类型的未知键:

interface ComboObject {
  known: boolean
  field: number
  [U: string]: string
}

const comboObject: ComboObject = {
  known: true
  field: 123
  unknownName: 'value'
}

这段代码不起作用,因为TypeScript要求所有属性与给定索引签名的类型匹配。 然而,我不想使用索引签名,我想为我知道其类型但不知道名称的单个字段进行类型定义。

到目前为止,我唯一的解决方案是使用索引签名并建立所有可能类型的联合类型:

interface ComboObject {
  [U: string]: boolean | number | string
}

但是这种方法有很多缺点,包括允许在已知字段上使用不正确的类型,并允许任意数量的未知键。

是否有更好的方法? TypeScript 2.8 条件类型能帮助解决这个问题吗?


TypeScript并不适合这种情况。我有一种方法可以通过条件类型来强制编译器将函数参数限制为与您预期的“ComboObject”类型匹配(正好有一个额外的键和字符串属性,没有其他属性),但这种方法非常糟糕,不建议在任何生产代码中使用。如果您感兴趣,我可以发布它,但我认为您可能想追求其他更适合TypeScript的选项。 - jcalz
@jcalz 如果你能发布或以其他方式发送它,那就太好了,即使它不完全可行,它也可能激发一些想法。 - Jacob Gillespie
1
这是一个非常特殊的情况,您需要确切地拥有3个属性,其中2个已知,1个未知。我曾经遇到过这样的情况,其中我有一个已知属性和任意数量的未知字符串。我像这样做:props: { children: string[] | VDOMElement[] } & { [key: string]: string }; 然而,如果不使用 & 运算符将它们组合在一起,则无法正常工作:props: { children: string[] | VDOMElement[], [key: string]: string }; - CodeFinity
1
即使使用& {[key: string]: string}的方法,通常也无法将已知属性与字符串联合起来,因为已知属性不能与字符串进行联合:https://www.typescriptlang.org/play?#code/CYUwxgNghgTiAEkoGdnwGoBEDyBZAohCALYgB2ALvAN4C+AUPRQJ4AOC+AHlMa0fAF4aiABYBLCMDhkAXPGQUYYsgHMA2gF14AHww4CRUpU3xa8AGTC1AaxDM5Cpao0PFylacagkceADMAVzIwCjEAezJ4EG5eIgAKADcoCDkuHj4QAEo5BLCxYEZo9PjqMHFJaTk1AHIREAgIMOqNWkz4AHp2qJgYMJh4ACNwKADkBAADMokpcnHEKDIyMKoh+Ch5N1U1smBttZ6oZiA - Jacob Gillespie
我的实际代码中没有红线……但是还没有。而且,我没有通过简化 tsconfig 来“作弊”。它非常严格。 - CodeFinity
4个回答

32

你需要的到了。

让我们进行一些类型操作,以便检测给定类型是否为联合类型。其工作方式是使用条件类型的分配属性来将联合类型扩展到组成部分,然后注意到每个组成部分都比联合类型更窄。如果不是这样,那么就是因为联合类型只有一个组成部分(因此它不是联合类型):

type IsAUnion<T, Y = true, N = false, U = T> = U extends any
  ? ([T] extends [U] ? N : Y)
  : never;

然后使用它来检测给定的{{string}}类型是否为单个字符串文字(因此不是{{string}},不是{{never}},也不是联合类型):
type IsASingleStringLiteral<
  T extends string,
  Y = true,
  N = false
> = string extends T ? N : [T] extends [never] ? N : IsAUnion<T, N, Y>;

现在我们可以开始谈论您的具体问题了。将BaseObject定义为ComboObject的一部分,您可以直接定义:
type BaseObject = { known: boolean, field: number };

准备处理错误消息,让我们定义一个ProperComboObject,这样当您犯错时,错误消息会提示您应该做什么:

interface ProperComboObject extends BaseObject {
  '!!!ExactlyOneOtherStringPropertyNoMoreNoLess!!!': string
}

接下来是主菜。 VerifyComboObject<C> 接受一个类型 C,如果它符合您所需的 ComboObject 类型,则不做修改返回;否则,为错误返回 ProperComboObject(它也不符合要求)。

type VerifyComboObject<
  C,
  X extends string = Extract<Exclude<keyof C, keyof BaseObject>, string>
> = C extends BaseObject & Record<X, string>
  ? IsASingleStringLiteral<X, C, ProperComboObject>
  : ProperComboObject;

它的工作原理是将C解析为BaseObject和其余的键X。如果C不符合BaseObject & Record<X, string>,那么就失败了,因为这意味着它要么不是一个BaseObject,要么是一个具有额外非string属性的BaseObject。然后,它通过使用IsASingleStringLiteral<X>检查X来确保仅剩下一个键。

现在我们创建一个辅助函数,该函数要求输入参数与VerifyComboObject<C>匹配,并返回未更改的输入。如果只想要正确类型的对象,则可以使用该函数及时捕捉错误。或者您可以使用签名来帮助使您自己的函数要求正确的类型:

const asComboObject = <C>(x: C & VerifyComboObject<C>): C => x;

让我们来测试一下:

const okayComboObject = asComboObject({
  known: true,
  field: 123,
  unknownName: 'value'
}); // okay

const wrongExtraKey = asComboObject({
  known: true,
  field: 123,
  unknownName: 3
}); // error, '!!!ExactlyOneOtherStringPropertyNoMoreNoLess!!!' is missing

const missingExtraKey = asComboObject({
  known: true,
  field: 123
}); // error, '!!!ExactlyOneOtherStringPropertyNoMoreNoLess!!!' is missing

const tooManyExtraKeys = asComboObject({
  known: true,
  field: 123,
  unknownName: 'value',
  anAdditionalName: 'value'
}); // error, '!!!ExactlyOneOtherStringPropertyNoMoreNoLess!!!' is missing

第一个编译通过,符合要求。后面三个因为额外属性的数量和类型不同而失败。错误信息有点晦涩,但这是我能做到的最好的。

你可以在Playground中看到代码的实际运行效果。

再次强调,我不认为我建议在生产代码中使用这种方法。我喜欢玩弄类型系统,但这个方法感觉特别复杂且脆弱, 我不想对任何意外后果负责。

希望这可以帮到你。祝好运!


这太棒了,谢谢!这肯定有助于理解类型系统的工作方式。最后一个问题,未知的键是否可以具有接口类型而不是字符串类型?我本以为通过将Record<X,string>更改为Record<X,InterfaceType>可能会实现,但似乎这允许在接口中任意添加属性。 - Jacob Gillespie
1
“任意属性”是什么意思?它不会允许您省略属性或给出错误类型,对吗?如果你指的是多余的属性,那倒不奇怪。在TypeScript中,多余属性检查只发生在特定的地方。它没有真正的确切类型,强制编译器拒绝多余属性将需要另一轮type golf - jcalz
有趣的是,你是对的,它允许额外的属性,但正确地检查给定的属性。感谢您提供GitHub链接,这也解释了如何获得类似的行为。 - Jacob Gillespie
这个例子在TypeScript 3.5.1中失败,出现错误“类型'keyof C'不能分配给类型'string'”。 - Matthew Dean
10
截至最新版本,TypeScript 中是否有更简单的方法来实现这个?我只是想要像下面这样的东西: {foo: number; [key: notFoo]: string} - Matthew Dean
2
如果你想让 notFoo 成为一个单一的字符串键,那么上面的方法可能仍然是我会采用的。如果你只想让 notFoo 成为任何不是 foo 的键或键集,那么你仍然需要使用一个通用映射条件类型,但它会更简单一些。在 TypeScript 实现任意索引签名类型否定类型之前,没有具体的方法来表示 {foo: number; [k: string & not "foo"]: string} - jcalz

8

在我看来,更简单的方法是使用交叉类型:

type ComboObject = {
  known: boolean
  field: number
} & {
  [key: string]: string | number | boolean;
};

这告诉 TypeScript 从左侧继承,但是当你提供额外的未知参数类型时不会发生错误。

一个稍微冗长和复杂的解决方案,但可以通过明确地Exclude已知类型来提供一些额外的约束:

type ComboObject = {
  known: boolean;
  field: number;
} & Record<Exclude<string, "known" | "field">, string | number | boolean>;

const combine: ComboObject = {
  known: true,
  field: 1,
  name: "name",
  age: 1,
  isAlive: true,
};

combone?.known // boolean
combone?.field // number
combone?.name // string | number | boolean
combone?.age // string | number | boolean
combone?.isAlive // string | number | boolean

1
此方法仅适用于未知键的类型(在此示例中为 string | number | boolean)是所有可能已知键值类型的联合。您可以将问题的目标视为相反,即“如果所有未知键都是字符串,但这些特定已知键是布尔值和数字,该怎么办”。 - Jacob Gillespie
@JacobGillespie 我同意你评论中的结论,但我也希望保持复杂度非常低,所以我宁愿让它合理正确,并能在一分钟内向我的同事解释清楚。 TypeScript 可以改善这种情况。在那之前,我会选择这个答案。 - ThaJay

6

很棒,@jcalz。

这给了我一些好的见解,让我到达了想要去的地方。 我有一个带有一些已知属性的BaseObject,并且BaseObject可以拥有任意数量的BaseObjects。

type BaseObject = { known: boolean, field: number };
type CoolType<C, X extends string | number | symbol = Exclude<keyof C, keyof BaseObject>> = BaseObject & Record<X, BaseObject>;
const asComboObject = <C>(x: C & CoolType<C>): C => x;

const tooManyExtraKeys = asComboObject({
     known: true,
     field: 123,
     unknownName: {
         known: false,
         field: 333
     },
     anAdditionalName: {
         known: true,
         field: 444
     },
});

通过这种方式,我可以在不过多更改原有结构的情况下,对其进行类型检查。

ty


1

好的,这对我来说是一个大问题,但我终于找到了一个好的、简单的解决方案!

export type Overwrite<Base, Overrides> = Omit<Base, keyof Overrides> & Overrides;

export type LocationsType = Overwrite<
  {
    [key: string]: LocationType
  },
  {
    add: (location: LocationData) => void
    addMultiple: (locationsData?: LocationsData) => void
    getAllSources: (maxAmount?: number) => SourceType[]
  }
>

所以这意味着所有的键都是字符串,其值为 LocationType,除了 overrides 中的键,这些特定字符串键具有指定的值。
不再需要 { [key: string]: LocationType | () => void | (someParam:string) => void | any }

为了完整起见,这只是一个小改变,因为我已经在这个模式中使用了Overwrite

const customerKeys = ['id', 'firstName', 'lastName', 'birthDate'] as const
type CustomerKey = typeof customerKeys[number]
export type CustomerData = Record<CustomerKey, string>
export type CustomerType = Overwrite<
  CustomerData,
  {
    id: number
    birthDate: Date
    locations: LocationsType
  }
>

这将输出一个JSON响应,解析后的可用结果作为数据和类型。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接