Membangun Utility Types dari Nol

Mengimplementasikan ulang Partial, Readonly, dan kawan-kawannya untuk memahaminya.

Daftar isi

Utility type adalah alat bantu tipe bawaan TypeScript, dan hampir semuanya mapped type pendek yang dapat ditulis ulang dalam satu baris. Merekonstruksi Partial, Readonly, Pick, Omit, dan Record dari nol memperlihatkan mekanisme keyof, akses terindeks, serta pewarisan modifier yang bekerja di baliknya. Topik ini membangun ulang alat bantu tersebut satu per satu, lalu membahas sifat homomorphic yang membuat modifier tipe sumber ikut terbawa.

Partial dan Readonly dari nol#

Dua utility type yang paling sering dipakai memiliki definisi yang paling pendek. Partial<T> ditulis { [K in keyof T]?: T[K] }, yaitu setiap kunci T dipetakan ulang dengan modifier ? dan tipe nilai yang dibaca melalui akses terindeks T[K]. Readonly<T> memakai struktur yang sama dengan modifier readonly menggantikan ?. Kedua definisi ini persis sama dengan bentuk yang tercantum pada pustaka bawaan TypeScript.

ts partial-readonly.ts
type Expect<T extends true> = T;
type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends
  (<T>() => T extends Y ? 1 : 2) ? true : false;

type PartialKu<T> = { [K in keyof T]?: T[K] };
type ReadonlyKu<T> = { readonly [K in keyof T]: T[K] };

type Pengukuran = { titik: string; suhu: number; kelembapan: number };

const b1: Expect<Equal<PartialKu<Pengukuran>, Partial<Pengukuran>>> = true;
const b2: Expect<Equal<ReadonlyKu<Pengukuran>, Readonly<Pengukuran>>> = true;

const draf: PartialKu<Pengukuran> = { titik: "B1" };
const terkunci: ReadonlyKu<Pengukuran> = { titik: "B1", suhu: 22, kelembapan: 58 };

console.log(b1, b2, JSON.stringify(draf), terkunci.suhu);
text Keluaran
true true {"titik":"B1"} 22

Dua bukti Equal membandingkan hasil rekonstruksi dengan utility type bawaannya secara langsung, dan keduanya dinyatakan setara oleh compiler. Variabel draf kemudian menunjukkan pemakaian PartialKu untuk mengisi pengukuran secara bertahap, sedangkan terkunci menunjukkan varian yang propertinya tidak dapat ditugaskan ulang. Setelah mekanismenya terlihat, memakai versi bawaan menjadi keputusan yang disadari, bukan hafalan. Karena kunci dan tipe nilainya dibaca dari sumber, perubahan pada Pengukuran otomatis mengalir ke kedua varian tanpa penyuntingan tambahan.

Pick, Omit, dan Record dari nol#

Pick<T, K> memetakan union kunci K, bukan seluruh keyof T, dengan parameter tipe yang dibatasi K extends keyof T agar hanya kunci yang sah yang diterima. Omit<T, K> bukan pemetaan baru, melainkan Pick di atas sisa kunci, yaitu Exclude<keyof T, K> yang membuang anggota union melalui conditional type distributif. Record<K, T> memetakan union kunci apa pun ke satu tipe, dan parameternya dibatasi keyof any, yang merupakan union terlebar dari semua kunci yang mungkin.

ts pick-omit-record.ts
type Expect<T extends true> = T;
type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends
  (<T>() => T extends Y ? 1 : 2) ? true : false;

type PickKu<T, K extends keyof T> = { [P in K]: T[P] };
type OmitKu<T, K extends keyof T> = PickKu<T, Exclude<keyof T, K>>;
type RecordKu<K extends keyof any, T> = { [P in K]: T };

type Pengukuran = { titik: string; suhu: number; kelembapan: number };

const b1: Expect<Equal<PickKu<Pengukuran, "suhu">, Pick<Pengukuran, "suhu">>> = true;
const b2: Expect<Equal<OmitKu<Pengukuran, "titik">, Omit<Pengukuran, "titik">>> = true;
const b3: Expect<Equal<RecordKu<"pagi" | "siang", number>, Record<"pagi" | "siang", number>>> = true;

const ringkas: PickKu<Pengukuran, "suhu"> = { suhu: 22 };
const tanpaLokasi: OmitKu<Pengukuran, "titik"> = { suhu: 22, kelembapan: 58 };
const jadwal: RecordKu<"pagi" | "siang", number> = { pagi: 22, siang: 27 };

console.log(b1, b2, b3, JSON.stringify(ringkas), JSON.stringify(tanpaLokasi), JSON.stringify(jadwal));
text Keluaran
true true true {"suhu":22} {"suhu":22,"kelembapan":58} {"pagi":22,"siang":27}

Tiga bukti Equal menyatakan hasil rekonstruksi setara dengan versi bawaannya. Variabel ringkas hanya memuat suhu, tanpaLokasi memuat kebalikannya, dan jadwal menuntut kedua kunci pagi dan siang sekaligus karena Record menjadikan setiap kunci wajib. Perbedaan mendasar ketiganya ada pada sumber kuncinya, yaitu kunci yang dipilih, kunci yang dibuang, dan kunci yang ditetapkan sendiri.

Rantai OmitKu menuju PickKu sekaligus memperlihatkan utilitas level tipe yang dapat disusun satu sama lain, karena alat bantu kecil yang tepat lebih mudah digabungkan daripada satu definisi besar yang mencoba menyelesaikan semuanya. Susunan ini meniru definisi bawaannya, sehingga hasil akhirnya tidak menyimpang dari perilaku yang dipakai ekosistem. Ketika kebutuhan yang lebih khusus muncul, misalnya membuang kunci berdasarkan tipe nilainya, pendekatan yang sama dapat diperluas. Pemetaan ulang kunci dapat dipadukan conditional type tanpa menyentuh alat bantu yang sudah ada.

Sifat homomorphic mapped type#

Mapped type yang memetakan keyof T disebut homomorphic mapped type, karena bentuknya mengikuti tipe sumber termasuk modifier pada setiap propertinya. Properti yang tadinya readonly tetap readonly pada hasil, dan properti yang tadinya opsional tetap opsional meskipun pemetaannya tidak menyebut modifier itu. Sifat ini berlaku juga pada pemetaan atas parameter tipe yang dibatasi keyof T, sehingga PickKu ikut mewarisi modifier dari kunci yang dipilihnya.

ts modifier-homomorphic.ts
type Expect<T extends true> = T;
type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends
  (<T>() => T extends Y ? 1 : 2) ? true : false;

type Sumber = {
  readonly kode: string;
  suhu?: number;
};

type PartialKu<T> = { [K in keyof T]?: T[K] };
type WajibKu<T> = { [K in keyof T]-?: T[K] };
type BisaUbah<T> = { -readonly [K in keyof T]: T[K] };
type PickKu<T, K extends keyof T> = { [P in K]: T[P] };

const b1: Expect<Equal<PartialKu<Sumber>, { readonly kode?: string; suhu?: number | undefined }>> = true;
const b2: Expect<Equal<WajibKu<Sumber>, { readonly kode: string; suhu: number }>> = true;
const b3: Expect<Equal<BisaUbah<Sumber>, { kode: string; suhu?: number | undefined }>> = true;
const b4: Expect<Equal<PickKu<Sumber, "kode">, { readonly kode: string }>> = true;

console.log(b1, b2, b3, b4);
text Keluaran
true true true true

Bukti pertama menunjukkan PartialKu<Sumber> tetap membawa readonly pada kode, dan bukti keempat memperlihatkan perilaku yang sama pada PickKu untuk kunci yang dipilihnya. Dua rekonstruksi berikutnya mengarah sebaliknya, yaitu WajibKu dengan -? melepas sifat opsional sehingga setara dengan Required, sedangkan BisaUbah dengan -readonly melepas sifat readonly. Modifier dapat diterapkan maupun dilepas dengan sintaks yang hanya berbeda satu tanda.

Kesalahan umum#

Kekeliruan pertama adalah mengisi hasil Pick seolah seluruh properti sumber masih ada. Berkas berikut menugaskan objek berisi suhu dan titik ke hasil PickKu<Pengukuran, "suhu">, dan pesan compiler memperlihatkan bentuk hasil pemetaannya.

ts bentuk-pick.ts
// tidak-dijalankan
type PickKu<T, K extends keyof T> = { [P in K]: T[P] };

type Pengukuran = { titik: string; suhu: number; kelembapan: number };

const cek: { res: PickKu<Pengukuran, "suhu"> } = { res: { suhu: 22, titik: "B1" } };

console.log(cek);
bash
# berkas ini sengaja tidak lolos pemeriksaan tipe
tsc --noEmit --pretty false bentuk-pick.ts
text Keluaran
bentuk-pick.ts(6,69): error TS2353: Object literal may only specify known properties, and 'titik' does not exist in type 'PickKu<Pengukuran, "suhu">'.

Pesan menyatakan titik tidak ada pada tipe hasil, yang membuktikan pemetaan hanya mempertahankan kunci suhu. Perbaikannya adalah mengisi tepat kunci yang dipilih, atau mengganti Pick dengan Omit bila yang dimaksud adalah membuang kunci tertentu. Kekeliruan kedua yang serupa adalah memberikan kunci di luar keyof T kepada Omit, yang ditolak compiler karena parameter K dibatasi keyof T, sehingga nama kunci yang salah tertangkap sejak kompilasi. Pesan errornya menyebut batasan keyof Pengukuran secara eksplisit, sehingga kunci yang keliru mudah dikenali.

Kekeliruan ketiga adalah menganggap rekonstruksi berarti selalu menulis versi sendiri. Tujuan latihan ini adalah membaca mekanismenya, sedangkan kode harian tetap memakai utility type bawaan yang perilakunya mapan dan dikenal seluruh tim. Versi sendiri disimpan untuk kebutuhan yang memang tidak disediakan versi bawaan, misalnya kombinasi modifier yang lebih spesifik. Bila varian baru benar-benar diperlukan, definisi bawaannya tetap menjadi patokan perilaku yang harus diikuti.

Ringkasan#

  • Partial dan Readonly didefinisikan sebagai mapped type atas keyof T dengan modifier ? dan readonly.
  • Pick memetakan union kunci yang dipilih, Omit adalah Pick di atas sisa kunci, dan Record memetakan union kunci apa pun ke satu tipe.
  • Mapped type homomorphic mewarisi modifier tipe sumber, dan modifier dilepas dengan -? serta -readonly.
  • Rekonstruksi adalah alat belajar, sedangkan pemakaian harian tetap memilih utility type bawaan.

Referensi#