Объект слияния функции Typescript по определенному пути

У меня есть функция слияния, которая объединяет объекты по определенному пути.

const merge = (src, path, newObj) => {
   // this code works fine
}

Чтобы использовать эту функцию, я называю ее так:

interface IUser {
   user: {
      address: {
        street: string
      }
   }
}

interface IAddrNum {
   door: number
}

const User: IUser = {
   user: {
     address: {
        street: "New Street"
     }
   }
}


const mergeObj: IAddrNum = {
   door: 59
}

const newObj = merge(User, "user.address", mergeObj);

С этим я получаю правильный результат.

{
   user: {
     address: {
       street: "New Street"
       door: 59
     }
   }
}

Вопрос: Я хочу создать подпись для этой функции в машинописном тексте.

interface Immutable {
   merge<T, K, M>(
     src: T,
     path: K,
     merge: M
   ): T & M // <== this is where the problem is
}

Это не работает должным образом. Это не может быть T & K, потому что слияние должно происходить по определенному пути. Можно ли иметь подпись этой функции? Если да, не могли бы вы дать мне какое-нибудь направление? Спасибо.


person Abhishek Saha    schedule 03.03.2020    source источник
comment
Вы не можете сделать это с "user.address", поскольку компилятор не может анализировать строки на уровне типа. Вместо этого вам нужно будет использовать что-то вроде кортежей в форме ["user", "address"] as const, чтобы даже начать писать это. Или вы можете сделать параметр merge отдельным объектом, например {user: { address: {door: 59}}}, и вообще не упоминать путь (в этом случае тип возвращаемого значения действительно будет выглядеть примерно как T & M). Я был бы рад посоветовать любой из этих вариантов, но строковые литералы пути с точками не подходят.   -  person jcalz    schedule 03.03.2020
comment
Спасибо @jcalz. Я не был уверен, что "user.address" сработает. Спасибо за объяснение. Я могу это сделать ["user","address"]. Было бы здорово, если бы вы помогли мне внести это новое изменение.   -  person Abhishek Saha    schedule 03.03.2020


Ответы (1)


Если вы не против использовать кортеж пути, то нам нужно манипулировать кортежами. Полезным псевдонимом типа является Tail<T>, который принимает тип кортежа, например [string, number, boolean], и возвращает другой кортеж с удаленным первым элементом, например [number, boolean]:

type Tail<T extends any[]> = 
  ((...t: T) => void) extends ((h: any, ...r: infer R) => void) ? R : never;

Затем мы можем написать DeepRecord<K, V>, где K - путь ключей свойств, а V - некоторое значение. Мы создаем рекурсивный отображаемый условный тип, который создает объектный тип, в котором V находится вниз по пути, описанному K:

type DeepRecord<K extends PropertyKey[], V> =
    K extends [] ? V : { [P in K[0]]: DeepRecord<Tail<K>, V> };

Чтобы убедиться, что это работает, вот пример "

type Example = DeepRecord<["foo", "bar", "baz"], string>;
/* type Example = { foo: { bar: { baz: string; }; };} */

Теперь мы, по сути, закончили, но также неплохо сделать что-то, что рекурсивно объединяет пересечения, чтобы вместо {foo: {bar: {baz: string}}} & {foo: {bar: {qux: number}}} вы получали один {foo: {bar: {baz: string; qux: number;}}}:

type MergeIntersection<T> = 
  T extends object ? { [K in keyof T]: MergeIntersection<T[K]> } : T;

Наконец, мы можем дать merge() сигнатуру типа (и реализацию, хотя это выходит за рамки вопроса и не гарантирует ее правильность):

const merge = <T extends object, K extends N[] | [], 
  V extends object, N extends PropertyKey>(
    src: T, path: K, newObj: V
) => {
    const ret = { ...src } as MergeIntersection<T & DeepRecord<K, V>>;
    let obj: any = ret;
    for (let k of path) {
        if (!(k in obj)) {
            obj[k] = {};
        }
        obj = obj[k];
    }
    Object.assign(obj, newObj);
    return ret;
}

Обратите внимание, что N на самом деле мало что делает в определении, но помогает компилятору сделать вывод, что параметр path constains литералы, а не только string. И | [] в ограничении для K помогает компилятору вывести кортеж вместо массива. Вы хотите, чтобы ["user", "address"] выводился как тип ["user", "address"], а не как string[], иначе все развалится. Эта раздражающая магия - тема microsoft / TypeScript # 30680, а пока что это лучший Я могу сделать.

Вы можете протестировать это на своем примере кода:

const newObj = merge(User, ["user", "address"], mergeObj);
/* const newObj: {
    user: {
        address: {
            street: string;
            door: number;
        };
    };
}*/

console.log(JSON.stringify(newObj));
// {"user":{"address":{"street":"New Street","door":59}}}

Думаю, неплохо выглядит. Хорошо, надеюсь, что это поможет; удачи!

площадка ссылку на код

person jcalz    schedule 03.03.2020
comment
Спасибо @jcalz за красивое объяснение и разбор, чтобы я понял. Это сработало очень хорошо. - person Abhishek Saha; 04.03.2020
comment
на основе вашего кода я пытаюсь написать тип для del. В этом случае это будет del(User, ["user","address","street"]). Не могли бы вы помочь мне в этом? Я изменил DeepRecord для создания объекта, но я не могу вычесть это из исходного объекта. - person Abhishek Saha; 04.03.2020
comment
Это другой вопрос, не правда ли? Я был бы счастлив посмотреть на него, но не в разделе комментариев (и вы все равно можете опубликовать новый вопрос, чтобы привлечь внимание других на случай, если я не смогу до него добраться) - person jcalz; 04.03.2020
comment
Конечно, вот ссылка, которую я только что опубликовал - типы stackoverflow.com/questions/60529460/ - person Abhishek Saha; 04.03.2020