使用构建器模式创建一个强类型的、多样化的 Record(就像 Redux Toolkit 所做的那样)
Using builder pattern to create a strongly-typed, diverse Record (like Redux Toolkit does)
我正在尝试编写使用构建器模式的代码,以根据 Redux Toolkit 对其 createReducer
函数所做的工作动态构造强类型 records/maps。
换句话说,对于任意一组基本记录,例如:
type BaseRecord = {name: string} & Record<string, string | number>
const recordA: BaseRecord = {
name: "recordA",
fooString: "foo",
fooNumber: 6
}
const recordB: BaseRecord = {
name: "recordB",
barString: "bar",
barNumber: 2,
barSecondString: "bar2"
}
我想要一个构造函数,它使用构建器回调将它们放入强类型映射中,如下所示:
const newRecordMap = createRecordMap(builder=>{
builder.addRecord(recordA).addRecord(recordB)
})
其中 newRecordMap
最终输入为
{
recordA: typeof recordA
recordB: typeof recordB
}
而不是一些类型擦除的版本,例如:Record<string, BaseRecord>
我尝试了以下各种版本:
type RecordMapBuilder = {
addRecord: <T extends BaseRecord>(record:T)=>RecordMapBuilder
}
function createRecordMap(builderCallBack:(builder:RecordMapBuilder)=>void){
const recordMap = {}
const builder:RecordMapBuilder = {
addRecord: (record)=>{
recordMap[record.name] = record
return builder
}
}
builderCallback(builder)
return recordMap
}
问题基本上是 RecordMap
的输入(据我所知)。如果它很窄——如上所述——那么 addRecord
函数中的赋值将不起作用。如果它比传入的类型更广泛,则会被删除。
我研究了 createReducer and mapBuilder 的 RTK 代码,他们似乎能够启用这种类型的推断,使用他们的 CaseReducers
类型(第一个 [=59= 中的第 65 行) ]), 但在我的简化示例中我无法理解为什么它有效或如何使其有效。
Here's a playground 说明问题。
如何让类型推断在这里正常工作?
编辑
我打算悬赏这个问题,说明我正在寻找规范的答案。我发现我经常在寻找这样的模式,即以某种方式以编程方式构建强类型 Record(或 Map),其中每个 Record 都有自己的类型,可以扩展一些通用类型,但最终的 Record 是不是类型擦除的记录,而是像您手动写出记录一样精确。我相信这是可能的,因为 Redux Toolkit 在几个地方使用了这种模式,上面的问题是为了说明我在实施类似 RTK 的解决方案时遇到的困难。
就是说,我愿意接受任何提供规范方法的答案,以在上述问题中解释的那种类型的 TypeScript 中构建强类型、多样化的记录。
编辑 2
@Алексей Мартинкевич 的回答有一些有用的见解(它可能是最好的),但它需要使用 class 和一些断言。我一直在尝试使用函数式方法让它工作,但我仍然无法正确地进行通用推理。
目前,我坚持:
// Base types and builder function
type BaseRecord<Name extends string> = {name: Name} & Record<string, string | number>
type RecordName<Rec extends BaseRecord<string>> = Rec extends BaseRecord<
infer Name
>
? Name
: never;
type ToMapItem<Rec extends BaseRecord<string>> = {
[key in RecordName<Rec>]: Rec;
}
type RecordMapBuilder<TRecordMap extends Record<string, BaseRecord<string>>> = {
addRecord<Name extends string, BR extends BaseRecord<Name>>(record: BR):RecordMapBuilder<TRecordMap & ToMapItem<BR>>
}
function createRecordMap<TRecordMapBuilder extends RecordMapBuilder<any>>(builderCallBack:(builder:TRecordMapBuilder)=>void){
const recordMap:RecordMapBuilder<{}> = {}
const builder = {
addRecord<TName extends string, TBaseRecord extends BaseRecord<TName>>(record:TBaseRecord){
(recordMap as any)[record.name] = record
return builder
}
}
builderCallBack(builder)
return recordMap as TRecordMapBuilder
在 this playground。我错过了什么?抱歉,如果我挑剔,但这是一个规范答案的赏金问题...
通常要实现类似的功能,您需要使用泛型。
让我们从 BaseRecord
类型开始。原始版本丢失了很多信息。您需要知道每个记录的名称才能构建地图。此外,当您分配 const foo: BaseRecord = { /*...*/ }
时,foo
的类型将减少为 BaseRecord
,并且有关确切字段名称的所有信息都会丢失。
有两种处理方法。首先是在声明记录时避免 BaseRecord
类型:
const example = {
name: "foo",
bar: 0,
baz: "",
} as const;
这是一种简单的方法,但它有一些缺点。对象变成 readonly
可能适合也可能不适合你。并且不检查不正确的值。
const example = {
name: "foo",
bar: 0,
baz: {},
} as const;
// This is fine too.
当然,当你在某个地方使用它时,第二个对象仍然会给你打字稿错误 BaseRecord
是预期的。
另一种方法是使用泛型
type BaseRecord<Name extends string> = { name: Name } & Record<
string,
string | number
>;
// In this case we cannot simply declare variable, so there is a simple helper
function makeRecord<Name extends string, Rec extends BaseRecord<Name>>(
rec: Rec
) {
return rec;
}
const r1 = makeRecord({
name: "A",
foo: 1,
bar: 2,
baz: "3",
});
现在构建器的主要思想如下:
// Simple helper to get Name from BaseRecord
type RecordName<Rec extends BaseRecord<string>> = Rec extends BaseRecord<
infer Name
>
? Name
: never;
// Another helper to convert Rec<Name> to { [Name]: Rec[Name] }
type ToMapItem<Rec extends BaseRecord<string>> = {
[key in RecordName<Rec>]: Rec;
};
// We store all records in this ResultMap generic argument
export class RecordMapBuilder<ResultMap extends Record<string, BaseRecord<string>>> {
private map: ResultMap;
constructor(map: ResultMap) {
this.map = map;
}
// Each time we call addRecord we add new item to the generic argument
addRecord<Name extends string, Rec extends BaseRecord<Name>>(
record: Rec
): RecordMapBuilder<ResultMap & ToMapItem<Rec>> {
// Unfortunately this implementation is not type safe internally
// Perhaps typecast can be avoided if we return `new Builder` instead
// But I guess it would be a bit of an overkill
(this.map as any)[record.name] = record;
// Same here
return this as RecordMapBuilder<{}> as RecordMapBuilder<
ResultMap & ToMapItem<Rec>
>;
}
build() {
return this.map;
}
}
使用示例:
const r1 = makeRecord({
name: "d",
foo: 1,
bar: 2,
baz: "3",
});
const r2 = {
name: "e",
x: 0,
y: 0,
} as const;
const map = new RecordMapBuilder({})
// notice that we don't need makeRecord as we pass object literal directly into addRecord method
.addRecord({
name: "a",
numValue: 123,
})
.addRecord({
name: "b",
stringValue: "abc",
})
.addRecord({
name: "c",
numValue: 123,
stringValue: "abc",
})
.addRecord(r1)
.addRecord(r2)
.build();
console.log(map.a.numValue);
// Error
console.log(map.a.stringValue);
// Error
console.log(map.b.numValue);
console.log(map.b.stringValue);
console.log(map.c.numValue);
console.log(map.c.stringValue);
最后要实现 createRecordMap
功能,我们需要另一个泛型。
要正确推断地图的类型,我们必须 return 从回调中构建。
function createRecordMap<RecordMap extends Record<string, BaseRecord<string>>>(
builderCallBack: (
builder: RecordMapBuilder<{}>
) => RecordMapBuilder<RecordMap>
) {
return builderCallBack(new RecordMapBuilder({})).build();
}
const map = createRecordMap((builder) => builder.addRecord(r1).addRecord(r2));
编辑:
// Desired use by consumer
const recordA = {
name: "recordA",
fooString: "foo",
fooNumber: 6
}
// Unfortunately this is impossible because of the way typescript infers type here
// recordA: {
// name: string;
// fooString: string;
// fooNumber: number;
// }
// The problem here is that `name` has type `string` and we need it to be `"recordA"`
// This is happening because we can change the name later
// i.e.
recordA.name = "foo";
// I guess the closest way is the following:
const recordA = {
name: "recordA" as const,
fooString: "foo",
fooNumber: 6
}
// or
const recordA = {
name: "recordA" as "recordA",
fooString: "foo",
fooNumber: 6
}
// This marks the name as readonly property so typescript know it cannot be changed to other value.
现在谈谈建造者。 Class这里没有必要。主要技巧是 addRecord
returns 对象带有新的 TRecordMap
参数。但是,有必要在回调结束时 return 构建器。这样做的原因是变量的类型不能改变,你只能创建新的类型。您可以将其视为类型的纯函数。所以获得结果类型的唯一方法是return它。
type RecordMapBuilder<TRecordMap extends Record<string, BaseRecord<string>>> = {
addRecord<Name extends string, BR extends BaseRecord<Name>>(record: BR): RecordMapBuilder<TRecordMap & ToMapItem<BR>>
}
type RecordMapFromBuilder<Builder extends RecordMapBuilder<any>> = Builder extends RecordMapBuilder<infer RecordMap> ? RecordMap : never;
function createRecordMap<TRecordMapBuilder extends RecordMapBuilder<any>>(builderCallBack: (builder: RecordMapBuilder<{}>) => TRecordMapBuilder) {
const recordMap: Record<string, BaseRecord<string>> = {}
const builder: RecordMapBuilder<{}> = {
addRecord(record) {
recordMap[record.name] = record
return builder
}
}
const res = builderCallBack(builder);
return recordMap as RecordMapFromBuilder<typeof res>
}
// correctly typed
const newRecordMap = createRecordMap(builder =>
builder.addRecord(recordA).addRecord(recordB)
)
// or
const newRecordMap2 = createRecordMap(builder => {
return builder.addRecord(recordA).addRecord(recordB)
})
我正在尝试编写使用构建器模式的代码,以根据 Redux Toolkit 对其 createReducer
函数所做的工作动态构造强类型 records/maps。
换句话说,对于任意一组基本记录,例如:
type BaseRecord = {name: string} & Record<string, string | number>
const recordA: BaseRecord = {
name: "recordA",
fooString: "foo",
fooNumber: 6
}
const recordB: BaseRecord = {
name: "recordB",
barString: "bar",
barNumber: 2,
barSecondString: "bar2"
}
我想要一个构造函数,它使用构建器回调将它们放入强类型映射中,如下所示:
const newRecordMap = createRecordMap(builder=>{
builder.addRecord(recordA).addRecord(recordB)
})
其中 newRecordMap
最终输入为
{
recordA: typeof recordA
recordB: typeof recordB
}
而不是一些类型擦除的版本,例如:Record<string, BaseRecord>
我尝试了以下各种版本:
type RecordMapBuilder = {
addRecord: <T extends BaseRecord>(record:T)=>RecordMapBuilder
}
function createRecordMap(builderCallBack:(builder:RecordMapBuilder)=>void){
const recordMap = {}
const builder:RecordMapBuilder = {
addRecord: (record)=>{
recordMap[record.name] = record
return builder
}
}
builderCallback(builder)
return recordMap
}
问题基本上是 RecordMap
的输入(据我所知)。如果它很窄——如上所述——那么 addRecord
函数中的赋值将不起作用。如果它比传入的类型更广泛,则会被删除。
我研究了 createReducer and mapBuilder 的 RTK 代码,他们似乎能够启用这种类型的推断,使用他们的 CaseReducers
类型(第一个 [=59= 中的第 65 行) ]), 但在我的简化示例中我无法理解为什么它有效或如何使其有效。
Here's a playground 说明问题。
如何让类型推断在这里正常工作?
编辑
我打算悬赏这个问题,说明我正在寻找规范的答案。我发现我经常在寻找这样的模式,即以某种方式以编程方式构建强类型 Record(或 Map),其中每个 Record 都有自己的类型,可以扩展一些通用类型,但最终的 Record 是不是类型擦除的记录,而是像您手动写出记录一样精确。我相信这是可能的,因为 Redux Toolkit 在几个地方使用了这种模式,上面的问题是为了说明我在实施类似 RTK 的解决方案时遇到的困难。
就是说,我愿意接受任何提供规范方法的答案,以在上述问题中解释的那种类型的 TypeScript 中构建强类型、多样化的记录。
编辑 2
@Алексей Мартинкевич 的回答有一些有用的见解(它可能是最好的),但它需要使用 class 和一些断言。我一直在尝试使用函数式方法让它工作,但我仍然无法正确地进行通用推理。
目前,我坚持:
// Base types and builder function
type BaseRecord<Name extends string> = {name: Name} & Record<string, string | number>
type RecordName<Rec extends BaseRecord<string>> = Rec extends BaseRecord<
infer Name
>
? Name
: never;
type ToMapItem<Rec extends BaseRecord<string>> = {
[key in RecordName<Rec>]: Rec;
}
type RecordMapBuilder<TRecordMap extends Record<string, BaseRecord<string>>> = {
addRecord<Name extends string, BR extends BaseRecord<Name>>(record: BR):RecordMapBuilder<TRecordMap & ToMapItem<BR>>
}
function createRecordMap<TRecordMapBuilder extends RecordMapBuilder<any>>(builderCallBack:(builder:TRecordMapBuilder)=>void){
const recordMap:RecordMapBuilder<{}> = {}
const builder = {
addRecord<TName extends string, TBaseRecord extends BaseRecord<TName>>(record:TBaseRecord){
(recordMap as any)[record.name] = record
return builder
}
}
builderCallBack(builder)
return recordMap as TRecordMapBuilder
在 this playground。我错过了什么?抱歉,如果我挑剔,但这是一个规范答案的赏金问题...
通常要实现类似的功能,您需要使用泛型。
让我们从 BaseRecord
类型开始。原始版本丢失了很多信息。您需要知道每个记录的名称才能构建地图。此外,当您分配 const foo: BaseRecord = { /*...*/ }
时,foo
的类型将减少为 BaseRecord
,并且有关确切字段名称的所有信息都会丢失。
有两种处理方法。首先是在声明记录时避免 BaseRecord
类型:
const example = {
name: "foo",
bar: 0,
baz: "",
} as const;
这是一种简单的方法,但它有一些缺点。对象变成 readonly
可能适合也可能不适合你。并且不检查不正确的值。
const example = {
name: "foo",
bar: 0,
baz: {},
} as const;
// This is fine too.
当然,当你在某个地方使用它时,第二个对象仍然会给你打字稿错误 BaseRecord
是预期的。
另一种方法是使用泛型
type BaseRecord<Name extends string> = { name: Name } & Record<
string,
string | number
>;
// In this case we cannot simply declare variable, so there is a simple helper
function makeRecord<Name extends string, Rec extends BaseRecord<Name>>(
rec: Rec
) {
return rec;
}
const r1 = makeRecord({
name: "A",
foo: 1,
bar: 2,
baz: "3",
});
现在构建器的主要思想如下:
// Simple helper to get Name from BaseRecord
type RecordName<Rec extends BaseRecord<string>> = Rec extends BaseRecord<
infer Name
>
? Name
: never;
// Another helper to convert Rec<Name> to { [Name]: Rec[Name] }
type ToMapItem<Rec extends BaseRecord<string>> = {
[key in RecordName<Rec>]: Rec;
};
// We store all records in this ResultMap generic argument
export class RecordMapBuilder<ResultMap extends Record<string, BaseRecord<string>>> {
private map: ResultMap;
constructor(map: ResultMap) {
this.map = map;
}
// Each time we call addRecord we add new item to the generic argument
addRecord<Name extends string, Rec extends BaseRecord<Name>>(
record: Rec
): RecordMapBuilder<ResultMap & ToMapItem<Rec>> {
// Unfortunately this implementation is not type safe internally
// Perhaps typecast can be avoided if we return `new Builder` instead
// But I guess it would be a bit of an overkill
(this.map as any)[record.name] = record;
// Same here
return this as RecordMapBuilder<{}> as RecordMapBuilder<
ResultMap & ToMapItem<Rec>
>;
}
build() {
return this.map;
}
}
使用示例:
const r1 = makeRecord({
name: "d",
foo: 1,
bar: 2,
baz: "3",
});
const r2 = {
name: "e",
x: 0,
y: 0,
} as const;
const map = new RecordMapBuilder({})
// notice that we don't need makeRecord as we pass object literal directly into addRecord method
.addRecord({
name: "a",
numValue: 123,
})
.addRecord({
name: "b",
stringValue: "abc",
})
.addRecord({
name: "c",
numValue: 123,
stringValue: "abc",
})
.addRecord(r1)
.addRecord(r2)
.build();
console.log(map.a.numValue);
// Error
console.log(map.a.stringValue);
// Error
console.log(map.b.numValue);
console.log(map.b.stringValue);
console.log(map.c.numValue);
console.log(map.c.stringValue);
最后要实现 createRecordMap
功能,我们需要另一个泛型。
要正确推断地图的类型,我们必须 return 从回调中构建。
function createRecordMap<RecordMap extends Record<string, BaseRecord<string>>>(
builderCallBack: (
builder: RecordMapBuilder<{}>
) => RecordMapBuilder<RecordMap>
) {
return builderCallBack(new RecordMapBuilder({})).build();
}
const map = createRecordMap((builder) => builder.addRecord(r1).addRecord(r2));
编辑:
// Desired use by consumer
const recordA = {
name: "recordA",
fooString: "foo",
fooNumber: 6
}
// Unfortunately this is impossible because of the way typescript infers type here
// recordA: {
// name: string;
// fooString: string;
// fooNumber: number;
// }
// The problem here is that `name` has type `string` and we need it to be `"recordA"`
// This is happening because we can change the name later
// i.e.
recordA.name = "foo";
// I guess the closest way is the following:
const recordA = {
name: "recordA" as const,
fooString: "foo",
fooNumber: 6
}
// or
const recordA = {
name: "recordA" as "recordA",
fooString: "foo",
fooNumber: 6
}
// This marks the name as readonly property so typescript know it cannot be changed to other value.
现在谈谈建造者。 Class这里没有必要。主要技巧是 addRecord
returns 对象带有新的 TRecordMap
参数。但是,有必要在回调结束时 return 构建器。这样做的原因是变量的类型不能改变,你只能创建新的类型。您可以将其视为类型的纯函数。所以获得结果类型的唯一方法是return它。
type RecordMapBuilder<TRecordMap extends Record<string, BaseRecord<string>>> = {
addRecord<Name extends string, BR extends BaseRecord<Name>>(record: BR): RecordMapBuilder<TRecordMap & ToMapItem<BR>>
}
type RecordMapFromBuilder<Builder extends RecordMapBuilder<any>> = Builder extends RecordMapBuilder<infer RecordMap> ? RecordMap : never;
function createRecordMap<TRecordMapBuilder extends RecordMapBuilder<any>>(builderCallBack: (builder: RecordMapBuilder<{}>) => TRecordMapBuilder) {
const recordMap: Record<string, BaseRecord<string>> = {}
const builder: RecordMapBuilder<{}> = {
addRecord(record) {
recordMap[record.name] = record
return builder
}
}
const res = builderCallBack(builder);
return recordMap as RecordMapFromBuilder<typeof res>
}
// correctly typed
const newRecordMap = createRecordMap(builder =>
builder.addRecord(recordA).addRecord(recordB)
)
// or
const newRecordMap2 = createRecordMap(builder => {
return builder.addRecord(recordA).addRecord(recordB)
})