typeguards - Bagaimana cara memetakan tipe dan menambahkan type guards berbasis this di TypeScript?


Misalkan ada sebuah antarmuka dengan anggota-anggota sebarang seperti ini:

interface A extends Record<string, any[]>{
  // Later used by interface merging
}

//maybe
declare module "./main"{
  interface A{
    func: [arg1:string]
  }
}

Dan saya ingin memetakan A seperti ini, dengan type guard berbasis ini:

interface Try1<T extends keyof A>{
  [K in keyof A]: (...args: A[K]) => this is Try1<K> 
  // Lots of errors
}
type Try2<T extends keyof A> = {
  [K in keyof A]: (...args: A[K]) => this is Try1<K> 
  // Error: A 'this' type is available only in a non-static member of a class or interface.ts(2526)
}

Jadi bagaimana saya bisa melakukannya?

Penggunaan nyata:

interface Elements extends Record<string, [any[], any]>{
  // Later used by interface merging
}

interface Elements{
  button: [[text: string], MouseEvent];
}

class ImguiContext<CurrentEv extends keyof Elements>{
  // !!!
  [E in keyof Elements]: (...args: Elements[E][0]) => this is ImguiContext<E>;

  event: Elements[CurrentEv][1];
}


function main(_: ImguiContext){
  if(_.button("Click me")){
    // now `_.event` should be `MouseEvent`
    console.log("clicked at X:", _.event.screenX);
  }
}

Solusi lain yang mungkin, tetapi gagal

let a:string|number = 1;
function aIsNumber(/*no parameter*/)/*: ???*/ {
  return typeof a === "number";
}

??? bisa

  1. Fungsi Assertion seperti asserts condition
  2. Predicate tipe seperti a adalah angka

tetapi keduanya gagal

Jawaban #1

Ternyata ini adalah fitur yang hilang di TypeScript untuk memungkinkan predikat tipe "this" (seperti yang awalnya diimplementasikan di microsoft/TypeScript#5906) dalam mapped types. Jika Anda mencoba melakukannya, Anda akan mendapatkan kesalahan yang Anda sebutkan. Ada permintaan fitur terbuka di microsoft/TypeScript#51958 untuk mendukung ini, tetapi untuk saat ini ini belum secara resmi menjadi bagian dari bahasa. Masalah ini tercantum sebagai "Menunggu umpan balik lebih lanjut", jadi jika Anda ingin melihat ini terjadi, tidak ada salahnya pergi ke sana, memberikan 👍, dan menjelaskan mengapa kasus penggunaan Anda menarik.

Menariknya, dalam masalah itu dicatat bahwa—meskipun ada kesalahan—kode tersebut sebenarnya berperilaku seperti yang diharapkan.

Jadi seseorang mungkin bisa mengeluarkan direktif komentar //@ts-ignore untuk menekan kesalahan, dan menganggap ini sebagai "fitur yang tidak didukung". Berikut ini adalah bagaimana tampilannya untuk contoh Anda:

interface Elements {
  button: [[text: string], MouseEvent];
  other: [[age: number, isSquishy: boolean], Date]; 
}

type ImguiContext<K extends keyof Elements = keyof Elements> = {
  //@ts-ignore this is usually a bad idea
  [E in keyof Elements]: (...args: Elements[E][0]) => this is ImguiContext<E>;
} & { event: Elements[K][1]; }
    
function main(_: ImguiContext) {
  if (_.button("Click me")) {
    // now `_.event` should be `MouseEvent`
    console.log("clicked at X:", _.event.screenX); // okay
  } else if (_.other(2, false)) {
    console.log("otherThing", _.event.getFullYear()); // okay
  }
}

Sebenarnya saya tidak menyarankan melakukan itu. Direktif //@ts-ignore hanya mencegah kesalahan ditampilkan; itu tidak memperbaiki masalah mendasar yang dimiliki oleh compiler. Mungkin itu tidak masalah karena masalah mendasar terbatas pada kesalahan yang dilaporkan, atau mungkin Anda akan menemui masalah tidak langsung yang aneh di bagian lain dari kode Anda karena hal itu. Dokumentasi untuk direktif tersebut mengatakan bahwa itu harus digunakan “sangat jarang” (penekanan dari mereka). Jadi gunakanlah dengan risiko Anda sendiri.


Sebaliknya, saya cenderung membungkus konteks Anda dalam versi yang tipe TypeScript dapat wakili tanpa kesalahan. Jika, alih-alih menjadi tipe yang dipetakan, ImguiContext memiliki metode test() yang menerima nama dan argumen, Anda bisa menulis ulang sebagai interface biasa:

interface ImprovedContext<T = unknown> {
  test<E extends keyof Elems>(
    name: E, ...args: Elems[E][0]
  ): this is ImprovedContext<Elems[E][1]> // no ts-ignore needed
  event: T
}

Jika Anda perlu menulis fungsi pembantu untuk membungkus konteks lama Anda dalam konteks baru, Anda bisa melakukannya, karena itu hanya memerlukan pernyataan tipe yang kurang berbahaya daripada //@ts-ignore:

function improveContext(_: ImguiContext): ImprovedContext {
  return {
    test(n, ...args) { return (_[n] as any)(...args) },
    event: _.event
  }
}

Dan kemudian semuanya masih berfungsi:

function main2(_Orig: ImguiContext) {
  const _ = improveContext(_Orig);
  if (_.test("button", "Click me")) {
    console.log("clicked at X:", _.event.screenX); // okay
  } else if (_.test("other", 2, false)) {
    console.log("otherThing", _.event.getFullYear()); // okay
  }
}

Tentu saja, itu tergantung pada kasus penggunaan Anda apakah lebih layak mengandalkan fitur yang tidak didukung atau melakukan refaktor ke versi yang dapat didukung.

Tautan playground ke kode