TypeScript: можно ли безопасно получить доступ к вложенным свойствам объекта с учетом массива ключей? Можно ли это сделать безопасным по типу и компонуемым способом?

Я хотел бы написать функцию, которая получает значение от объекта, заданного массивом ключей свойств. Это выглядело бы примерно так:

function getValue<O, K extends ObjKeys<O>>(obj: O, keys: K): ObjVal<O,K> {
  let out = obj;
  for (const k in keys) out = out[k]
  return out
}

Я бы хотел, чтобы эта функция работала так:

type Foo = {
  a: number
  b: string
  c: {
    lark: boolean
    wibble: string
  }
}

let o: Foo = {
  a: 1,
  b: "hi",
  c: {
    lark: true,
    wibble: "there"
  }
}

// I'd like these to type check (and return the expected values):
getValue(o, ['a']) // returns 1
getValue(o, ['b']) // returns "hi"
getValue(o, ['c','lark']) // returns true

// I'd like these _not_ to type check:
getValue(o, ['a','b'])
getValue(o, ['d'])

Важно отметить, что я хотел бы иметь доступный тип (например, ObjKeys<O> в приведенном выше примере), чтобы я мог легко использовать эту функцию из других функций, сохраняя при этом ввод. Например, я мог бы сделать что-то вроде этого:

function areValuesEqual<O>(obj: O, oldObj: O, keys: ObjKeys<O>) {
  let value = getValue(obj, keys)
  let oldValue = getValue(oldObj, keys)
 
  return value === oldValue ? true : false 
}

Эта функция принимает некоторые ключи и передает их в наш getValue выше, и в идеале она должна проверять все типы, потому что объект O и ключи ObjKeys<O> являются действительными аргументами для getValue функции, вызываемой внутри.

Это распространяется на возврат значения, возвращаемого getValue; Я мог бы также сделать что-то вроде этого:

function doSomethingAndThenGetValue<O>(obj: O, oldObj: O, keys: ObjKeys<O>): ObjVal<O> {
  let value = getValue(obj, keys)
  console.log("Value obtained is:", value)
  return value
}

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

Есть ли решение для этого, или просто нет возможности делать такие вещи в TypeScript в его нынешнем виде (версия 4 на момент написания)?

Лучшее, что у меня есть:

Я могу определить функцию, которая допускает вложенный доступ, примерно так:

function getValue<
    O  extends object, 
    K1 extends keyof O
>(obj: O, keys: [K1]): O[K1]
function getValue<
    O  extends object, 
    K1 extends keyof O, 
    K2 extends keyof O[K1]
>(obj: O, keys: [K1,K2]): O[K1][K2]
function getValue<
    O  extends object, 
    K1 extends keyof O, 
    K2 extends keyof O[K1], 
    K3 extends keyof O[K1][K2]
>(obj: O, keys: [K1,K2,K3]): O[K1][K2][K3]
function getValue<O>(obj: O, keys: Key | (Key[])): unknown {
  let out = obj;
  for (const k in keys) out = out[k]
  return out
}
type Key = string | number | symbol

И затем этот тип проверяется правильно, когда я пытаюсь получить доступ к значениям (в данном случае до трех уровней).

Однако я немного застреваю, когда хочу использовать эту функцию от другого, сохраняя безопасность типов:

function areValuesEqual<O>(obj: O, oldObj: O, keys: ????) {
  let value = getValue(obj, keys)
  let oldValue = getValue(oldObj, keys)
 
  return value === oldValue ? true : false 
}

function doSomethingAndThenGetValue<O>(obj: O, oldObj: O, keys: ????): ???? {
  let value = getValue(obj, keys)
  console.log("Value obtained is:", value)
  return value
}

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


person jsdw    schedule 29.08.2020    source источник


Ответы (1)


Это приближается к пределу того, что я могу извлечь из системы типов. TypeScript 4.1 будет поддерживать рекурсивные условные типы, но даже с ними, я полагаю, вы вполне возможно получение ошибок цикличности, слишком глубоких ошибок создания экземпляров типа или других странных ошибок во всем, что пытается использовать getValue() в общем. Так что я не уверен, что рекомендую вам использовать то, что я напишу ниже:


В другом вопросе я написал, как убедить компилятор дать вам объединение всех допустимых ключевых путей объекта, представленного в виде кортежа. Это выглядит так:

type Cons<H, T> = T extends readonly any[] ? [H, ...T] : never;
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
    11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...0[]]
type Paths<T, D extends number = 10> = [D] extends [never] ? never : T extends object ?
    { [K in keyof T]-?: [K] | (Paths<T[K], Prev[D]> extends infer P ?
        P extends [] ? never : Cons<K, P> : never
    ) }[keyof T]
    : [];

Вы можете убедиться, что

type FooPaths = Paths<Foo>;
// type FooPaths = ["a"] | ["b"] | ["c"] | ["c", "lark"] | ["c", "wibble"]

Следующее определение DeepIdx<T, KS> даст тип свойства в ключевом пути KS (где KS extends Paths<T> должно быть истинным), но in работает так только для TS4.1 +:

type DeepIdx<T, KS extends readonly any[]> = KS extends readonly [infer K, ...infer KK] ?
    K extends keyof T ? DeepIdx<T[K], KK> : never : T

Вы можете убедиться, что

type FooCWibble = DeepIdx<Foo, ["c", "wibble"]>;
// type FooCWibble = string

Вооружившись ими, ваш getValue() можно будет набрать вот так без перегрузок:

function getValue<O, KK extends Paths<O> | []>(
    obj: O, keys: KK
): DeepIdx<O, KK> {
    let out: any = obj;
    for (const k in keys) out = out[k as any]
    return out;
}

Вы можете убедиться, что они работают:

const num = getValue(o, ['a']) // number
const str = getValue(o, ['b']) // string 
const boo = getValue(o, ['c', 'lark']) // boolean

getValue(o, ['a', 'b']) // error!
// -------------> ~~~
// b is not assignable to lark | wibble
getValue(o, ['d']) // error!
// --------> ~~~
// d is not assignable to a | b | c

И тогда это определение также работает внутри areValuesEqual(), если вы зададите keys тип Paths<O>:

function areValuesEqual<O>(obj: O, oldObj: O, keys: Paths<O>) {
    let value = getValue(obj, keys)
    let oldValue = getValue(oldObj, keys)
    return value === oldValue ? true : false
}

Для doSomethingAndThenGetValue() вы должны сделать keys универсальным, чтобы компилятор знал, что выходит:

function doSomethingAndThenGetValue<O, K extends Paths<O>>(
  obj: O, 
  oldObj: O, 
  keys: K
): DeepIdx<O, K> {
    let value = getValue(obj, keys)
    console.log("Value obtained is:", value)
    return value
}

Я мог бы точно объяснить, как работают все эти типы, но это немного сложно, и есть некоторые конструкции, специально предназначенные для коаксиального вывода типов для правильной работы (| [] намекает на контекст кортежа) или во избежание немедленного предупреждения о цикличности (Prev tuple предназначен для размещения регулятора на максимальной глубине рекурсии), и я не знаю, насколько это полезно для подробного объяснения того, что я серьезно колеблюсь, вставляя какую-либо базу производственного кода.

Для ваших целей вы можете просто усилить ограничения во время выполнения и сделать что-то вроде PropertyKey[] для keys. Внутри реализации areValuesEqual() или вы можете просто использовать any[] или утверждение типа, чтобы компилятор принял его.


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

person jcalz    schedule 30.08.2020
comment
Спасибо, это был замечательный ответ! Вы тоже совершенно правы; это слишком сложно для меня, чтобы вводить его в общий код, поэтому я придумаю несколько альтернативных подходов к тому, над чем я работал. - person jsdw; 31.08.2020