Использование шаблона компоновщика для создания строго типизированной, разнообразной записи (как это делает инструментарий Redux)

#typescript #redux-toolkit

Вопрос:

Я пытаюсь написать код, который использует шаблон компоновщика для динамического построения строго типизированных записей/карт в соответствии с тем, что инструментарий Redux выполняет со своей createReducer функцией.

Другими словами, для произвольного набора базовых записей, например:

 type BaseRecord = {name: string} amp; 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 функции не будет работать. И если он шире, чем переданные типы, стираются.

Я изучил код RTK для createReducer и MapBuilder, и они, похоже, могут включить этот тип вывода, используя свой CaseReducers тип (строка 65 в первой ссылке), но я не могу понять, почему это работает или как заставить его работать в моем упрощенном примере.

Вот игровая площадка, иллюстрирующая эту проблему.

Как я могу заставить вывод типа работать правильно здесь?

Редактировать

Я собираюсь назначить награду за этот вопрос, заявив, что я ищу канонический ответ. Я нахожу, что довольно часто ищу подобный шаблон, т. Е. Какой-то способ создания программно строго типизированной записи (или карты), где каждая запись имеет свой собственный тип, расширяющий некоторый общий тип, но конечная запись не является записью со стиранием типа, а является такой точной, как если бы вы вручную записали запись. Я считаю, что это возможно, потому что инструментарий Redux использует, как представляется, этот шаблон в нескольких местах, и вопрос, сформулированный выше, должен был проиллюстрировать трудности, с которыми я столкнулся при реализации RTK-подобного решения.

Тем не менее, я открыт для любого ответа, который предоставляет канонический способ создания строго типизированной, разнообразной записи в машинописном виде, как описано в вопросе выше.

ПРАВКА 2

В ответе @Алексей Мартинкевич были некоторые полезные идеи (и, возможно, это лучшее, что есть), но для этого требуется использовать класс и ряд утверждений. Я пытался заставить его работать с функциональным подходом, но я все еще не могу сделать правильные общие выводы.
В настоящее время я застрял с:

 // Base types and builder function
type BaseRecord<Name extends string> = {name: Name} amp; 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 amp; 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
 

На этой детской площадке. Что я упускаю? Извините, если я придирчив, но это щедрый вопрос для канонического ответа…

Ответ №1:

В общем, чтобы добиться чего-то подобного, вам нужно использовать дженерики.

Давайте начнем с BaseRecord шрифта. Оригинальная версия потеряла много информации. Вам нужно знать имя каждой записи, чтобы построить карту. Кроме того, при назначении const foo: BaseRecord = { /*...*/ } foo тип s уменьшается до 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 } amp; 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",
});
 

Теперь основная идея для builder заключается в следующем:

 // 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 amp; 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 amp; 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 функции нам понадобится еще один универсальный.
Чтобы правильно определить тип карты, мы должны вернуть builder из обратного вызова.

 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.
 

Теперь о строителе. Класс здесь не нужен. Главный трюк заключается в том, что addRecord возвращает объект с новым TRecordMap аргументом. Однако необходимо вернуть builder в конце обратного вызова. Причина этого в том, что типы переменных нельзя изменить, вы можете только создавать новые типы. Вы можете думать об этом как о чистых функциях типов. Таким образом, единственный способ получить результирующий тип — это вернуть его.

 type RecordMapBuilder<TRecordMap extends Record<string, BaseRecord<string>>> = {
    addRecord<Name extends string, BR extends BaseRecord<Name>>(record: BR): RecordMapBuilder<TRecordMap amp; 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)
})
 

Игровая площадка

Комментарии:

1. спасибо вам за этот ответ. Это помогло мне понять некоторые проблемы с моим подходом, но я все еще пытаюсь понять, почему мое функциональное (неклассовое) решение не будет работать. Я отредактировал вопрос, чтобы сделать это более понятным. Есть какие-нибудь идеи? Спасибо!

2. Обновленный ответ @sam256

3. Спасибо!! Похоже, что вы также можете сделать это, чтобы ввести возврат: « builderCallBack(builder); верните карту записей как RecordMapFromBuilder<Тип возврата<тип обратного вызова><тип обратного вызова>> «

4. Кстати, на случай, если кто-то еще спустится в эту кроличью нору, кто-то в чате TS указал, что на самом деле это не чисто функциональный подход, поскольку this оф createRecordMap мутирует.