Tìm kiếm trang web

TypeScript: Có thể truy cập an toàn các thuộc tính lồng nhau của một đối tượng khi biết một mảng các khóa không? Điều này có thể được thực hiện một cách an toàn về kiểu và có thể kết hợp được không?


Tôi muốn viết một hàm lấy giá trị từ một đối tượng dựa trên một mảng các khóa thuộc tính. Nó sẽ trông giống như sau:

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
}

Tôi muốn chức năng này hoạt động như sau:

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'])

Quan trọng là, tôi muốn có một kiểu (như ObjKeys<O> trong ví dụ trên) sẵn có để tôi có thể sử dụng hàm này một cách dễ dàng từ các hàm khác, đồng thời vẫn giữ nguyên kiểu dữ liệu. Chẳng hạn, tôi có thể muốn làm điều gì đó như sau:

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 
}

Hàm này nhận một số khóa và chuyển chúng qua cho getValue ở trên của chúng tôi, và lý tưởng là tất cả sẽ kiểm tra kiểu vì đối tượng O và các khóa ObjKeys<O> đều là các đối số hợp lệ cho hàm getValue được gọi bên trong.

Điều này mở rộng đến việc trả về giá trị được getValue cung cấp; tôi cũng có thể muốn làm điều gì đó như thế này:

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
}

Điều này cũng sử dụng một cái gì đó giống như ObjVal<O> để biết kiểu trả về sẽ là gì, và do đó sẽ kiểm tra kiểu đầy đủ.

Có giải pháp nào cho việc này không, hay đơn giản là không có cách nào để làm điều này trong TypeScript như hiện tại (phiên bản 4 tại thời điểm viết)?

Đây là cái tốt nhất mà tôi có cho đến nay:

Tôi có thể định nghĩa một hàm cho phép truy cập lồng nhau với cái gì đó như sau:

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

Và sau đó điều này kiểm tra kiểu một cách chính xác khi tôi cố gắng truy cập các giá trị (tối đa 3 lớp trong trường hợp này).

Tuy nhiên, tôi gặp một chút khó khăn khi muốn sử dụng chức năng đó từ nơi khác trong khi vẫn giữ an toàn kiểu:

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
}

Tôi không chắc tôi có thể đặt gì thay cho ???? để nói với TypeScript cách các kiểu liên quan với nhau sao cho điều này có thể kiểm tra kiểu. Tôi có thể tránh phải viết lại danh sách lớn các overload mỗi lần tôi muốn viết các hàm như trên, nhưng vẫn có được kiểm tra kiểu mà tôi muốn không?

Câu trả lời #1

Điều này đang tiến gần đến giới hạn của những gì tôi có thể khai thác từ hệ thống kiểu. TypeScript 4.1 sẽ hỗ trợ các kiểu điều kiện đệ quy, nhưng ngay cả với chúng, tôi hình dung bạn rất có thể sẽ gặp lỗi vòng lặp, lỗi “khởi tạo kiểu quá sâu”, hoặc các lỗi lạ khác trên bất cứ thứ gì cố gắng sử dụng getValue() một cách tổng quát. Vì vậy tôi không chắc chắn mình có thực sự khuyên bạn sử dụng những gì tôi sắp viết bên dưới không:


Trong một câu hỏi khác, tôi đã viết cách thuyết phục trình biên dịch cung cấp cho bạn một hợp nhất của tất cả các đường dẫn khóa hợp lệ của một đối tượng, được biểu diễn dưới dạng một bộ.

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]
    : [];

Bạn có thể xác minh điều đó

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

Định nghĩa sau của DeepIdx<T, KS> sẽ cho kiểu của thuộc tính tại đường dẫn khóa KS (nơi KS extends Paths<T> nên đúng), nhưng nó chỉ hoạt động như thế này đối với 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

Bạn có thể xác minh điều đó

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

Trang bị những điều đó, getValue() của bạn có thể được gõ như thế này mà không cần quá tải:

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;
}

Bạn có thể xác nhận rằng những thứ này hoạt động:

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

Và sau đó định nghĩa này cũng hoạt động bên trong areValuesEqual() nếu bạn cho keys kiểu 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
}

Đối với doSomethingAndThenGetValue(), bạn phải làm cho keys trở nên tổng quát để trình biên dịch biết sẽ có gì được trả ra:

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
}

Tôi có thể giải thích chính xác cách tất cả những loại đó hoạt động, nhưng nó hơi phức tạp và có một số cấu trúc được thiết kế đặc biệt để khéo léo cho suy luận kiểu hoạt động đúng cách (mã | [] gợi ý một ngữ cảnh tuple) hoặc để tránh cảnh báo vòng lặp ngay lập tức (tuple Prev tồn tại để đặt một giới hạn trên độ sâu tối đa của đệ quy), và tôi không biết việc giải thích chi tiết sẽ hữu ích tới mức nào khi đó là điều mà tôi sẽ thực sự do dự nếu đưa vào bất kỳ cơ sở mã sản xuất nào.

Đối với mục đích của bạn, bạn có thể muốn chỉ thực thi các ràng buộc nhiều hơn khi chạy và làm điều gì đó như PropertyKey[] cho keys. Bên trong phần triển khai của areValuesEqual() hoặc bạn có thể chỉ cần sử dụng any[] hoặc một phép xác nhận kiểu để làm cho trình biên dịch chấp nhận nó.


Liên kết Playground đến mã