使用构建器模式创建一个强类型的、多样化的 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));

Playground

编辑:

// 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)
})

Playground