如何在声明对象值类型时不声明键类型?

5

问题声明

我想声明mapper1,使其值只能为Type1,并声明mapper2,使其值只能为Type2。我该如何在不声明键类型的情况下实现这一点?

背景

在TypeScript中,我有:

import Bar1 from './bar1'; // Type1
import Bar2 from './bar2'; // Type1
import Bar3 from './bar3'; // Type2
import Bar4 from './bar4'; // Type2

const mapper1 = {
  foo1: bar1,
  foo2: bar2,
} as const;
const mapper2 = {
  foo3: bar3,
  foo4: bar4,
} as const;
export type MapperKeys = keyof typeof mapper1 | keyof typeof mapper2;

"bar1"和"bar2"具有相同的类型("Type1")。"bar3"和"bar4"具有相同的类型("Type2")。"Type1"与"Type2"不同。
"MapperKeys"是"mapper1"和"mapper2"键的并集('foo1' | 'foo2' | 'foo3' | 'foo4')。

我尝试过的方法

方法1:

const mapper1: Record<string, Type1> = {
  foo1: bar1,
  foo2: bar2,
} as const;
const mapper2: Record<string, Type2> = {
  foo3: bar3,
  foo4: bar4,
} as const;

但现在MapperKeys'string'。我希望它成为mapper1mapper2键的并集('foo1' | 'foo2' | 'foo3' | 'foo4')。

方法二:

const mapper1: Record<'foo1' | 'foo2', Type1> = {
  foo1: bar1,
  foo2: bar2,
} as const;
const mapper2: Record<'foo3' | 'foo4', Type2> = {
  foo3: bar3,
  foo4: bar4,
} as const;

这个可以用,但不是DRY


请提供一个[mre],清楚地展示您所面临的问题。理想情况下,有人可以将代码粘贴到像TypeScript Playground(链接在此!)这样的独立IDE中,并立即开始解决问题,而无需首先重新创建它。因此,不应存在伪代码、拼写错误、无关错误或未声明的类型或值。 - jcalz
1
这种方法是否符合您的需求?如果您使用对象类型注释变量,就是告诉编译器它不应该尝试推断更具体的内容(例如,如果您注释为 Record<string, Type1>,那么您可以稍后编写 mapper1.someOtherKey = bar1,因此编译器无法确定 mapper1 的类型仅具有键 "foo1""foo2"。)如果您想要推断和约束检查,可以编写一个通用的帮助函数来实现,如我上面的链接所示。如果这有意义,我可以写出一个答案;如果没有,那么我错过了什么? - jcalz
@jcalz 这个方法看起来不错。你能否把它发表为一个回答,以便接受?然而,我不太理解你的解释,所以你能否详细解释并提供相关文档的链接? - Ivan Rubinson
2个回答

10
如果你在变量上使用类型注解,比如const x:T,或者在表达式上使用类型断言,比如x as T,那么你告诉编译器将变量或值视为该类型。这基本上丢弃了编译器可能推断出的更具体类型的信息*。 x的类型将会被扩大T
const badMapper1: Record<string, Type1> = { foo1: bar1, foo2: bar2 };
const badMapper2 = { foo3: bar3, foo4: bar4 } as Record<string, Type2>;    

export type BadMapperKeys = keyof typeof badMapper1 | keyof typeof badMapper2;
// type BadMapperKeys = string

相反,您需要寻找像microsoft/TypeScript#7481中请求的“satisfies运算符”,它将在TypeScript 4.9中引入。其想法是类似于表达式x satisfies T验证x是否可以分配给类型T而不是将其扩大为T。有了该运算符,您可以说类似于以下内容:

const mapper1 = { foo1: bar1, foo2: bar2 } satisfies { [key: string]: Type1 }
const mapper2 = { foo3: bar3, foo4: bar4 } satisfies { [key: string]: Type2 }

export type MapperKeys = keyof typeof mapper1 | keyof typeof mapper2;
// type MapperKeys = "foo1" | "foo2" | "foo3" | "foo4"

完成。


在 TypeScript 4.9 之前的版本中,您可以编写帮助函数以类似的方式运行。一般形式如下:

const satisfies = <T,>() => <U extends T>(u: U) => u;

然后,你需要将x satisfies T改为更冗长的satisfies<T>()(x)。这样做的原因是satisfies<T>()会生成一个形式为<U extends T>(u: U)=>u的恒等函数,其中输入类型U约束T,返回类型是更窄的类型U而不是更宽的类型T
const mapper1 = satisfies<Record<string, Type1>>()({ foo1: bar1, foo2: bar2 });
const mapper2 = satisfies<Record<string, Type2>>()({ foo3: bar3, foo4: bar4 });
export type MapperKeys = keyof typeof mapper1 | keyof typeof mapper2;
// type MapperKeys = "foo1" | "foo2" | "foo3" | "foo4"

看起来不错!


在您的情况下,您特别要求指定对象值类型而不是键。如果您愿意,您可以调整satisfies函数,以便您指定属性值类型T并让编译器推断出键。类似这样:
const satisfiesRecord = <T,>() => <K extends PropertyKey>(rec: Record<K, T>) => rec;

您可以看到它的行为类似:
const mapper1 = satisfiesRecord<Type1>()({ foo1: bar1, foo2: bar2, });
const mapper2 = satisfiesRecord<Type2>()({ foo3: bar3, foo4: bar4, });
export type MapperKeys = keyof typeof mapper1 | keyof typeof mapper2;
// type MapperKeys = "foo1" | "foo2" | "foo3" | "foo4"

代码的游乐场链接


*当您将变量注释为联合类型时,这并不是严格意义上的真实情况;在这种情况下,编译器会在赋值时缩小变量的类型。但由于Record<string, Type1>不是联合类型,因此这不适用于当前情况。

Cleaner satisfies 的讨论已移至 https://github.com/microsoft/TypeScript/issues/47920。 - patricksurry

0

使用显式类型会禁用不可变断言。考虑使用显式类型或as const断言。

为了实现所需的行为,您应该使用帮助函数和静态验证:

type Type1 = { type: 1 }
type Type2 = { type: 2 }

const bar1: Type1 = { type: 1 }
const bar2: Type1 = { type: 1 }

const bar3: Type2 = { type: 2 }
const bar4: Type2 = { type: 2 }

// credits goes to https://dev59.com/zlUL5IYBdhLWcg3wbne4#50375286
type UnionToIntersection<U> =
  (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never

// credits goes to https://dev59.com/RFQJ5IYBdhLWcg3wZU__
type IsUnion<T> = [T] extends [UnionToIntersection<T>] ? false : true

type IsValueValid<Obj> = Obj extends Record<infer _, infer Value> ? IsUnion<Value> extends true ? never : Obj : never

const builder = <Key extends string, Value>(obj: IsValueValid<Record<Key, Value>>) => obj

const result1 = builder({
  foo1: bar1,
  foo2: bar2,
}) // ok, all values have same type

result1.foo1 // ok
result1.foo2 // ok

const result2 = builder({
  foo1: bar3,
  foo2: bar4,
}) // ok, all values have same type

const result3 = builder({
  foo1: bar1,
  foo2: bar4,
}) // expected error, values have different type

游乐场

IsUnion - 检查对象值类型是否为联合类型。如果值具有不同的类型,则应将此对象视为无效。这正是我们在IsValueValid中所做的。如果提供的参数不符合我们的要求,此实用程序类型将返回never


1
我认为OP想要result1具体拥有Type1的值,而不仅仅是“相同类型”的值。但也许我理解错了? - jcalz
@jcalz 老实说,我不知道。 - captain-yossarian from Ukraine
这是一个非常复杂的解决方案……看起来 keyof typeof result1 | keyof typeof result2 确实解析为 'foo1' | 'foo2' | 'foo3' | 'foo4',正如所期望的那样。我认为它做了比要求更多的事情,这也许可以解释为什么它看起来如此复杂。 - Ivan Rubinson

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