#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
мутирует.