Tujuan
Di proyek ini kamu memigrasikan aplikasi Daftar Tugas (todo) React yang ditulis dengan JavaScript menjadi TypeScript dengan mode strict, secara bertahap, tanpa pernah membuat aplikasi berhenti berjalan. Ini persis situasi yang akan kamu hadapi di tempat kerja: jarang ada tim yang menulis ulang aplikasi dari nol hanya demi TypeScript. Yang terjadi adalah migrasi file demi file, sambil fitur baru tetap dikerjakan.
Kamu boleh memakai aplikasi todo atau katalog produk yang kamu buat di materi React, atau mulai dari kerangka di bawah. Hasil akhirnya:
| Bagian | Teknik TypeScript yang dipakai |
|---|---|
| Model data | Skema Zod + z.infer, Omit, indexed access Todo["prioritas"] |
| Ambil data API | fetch + validasi runtime, mapping dari bentuk API ke bentuk aplikasi |
| State | useReducer dengan discriminated union untuk state dan aksi |
| Komponen | Props bertipe, event ChangeEvent/SubmitEvent, generic component |
| Konfigurasi | tsconfig mode strict + allowJs untuk masa transisi |
| Fondasi | React 19.3, Vite 8.3 (template react-ts), TypeScript 7.0.2, Zod 4.6.7 |
Strategi migrasi yang kita pakai disebut "strict dari awal, JS dibiarkan sementara": konfigurasi langsung strict: true, file .js/.jsx lama tetap boleh ada berkat allowJs, lalu file dipindahkan ke .ts/.tsx satu per satu, dimulai dari yang paling sedikit ketergantungannya (utilitas dan model data), baru kemudian hooks dan komponen. Dengan cara ini setiap file yang sudah dimigrasi langsung mendapat perlindungan penuh, dan tidak ada momen "mematikan strict lalu lupa menyalakannya lagi".
Fitur wajib
- Konfigurasi TypeScript:
typescript7.x terpasang,tsconfig.app.jsondenganstrict: true,noUncheckedIndexedAccess: true,verbatimModuleSyntax: true, danerasableSyntaxOnly: true. Skripnpm run typecheckmenjalankantsc -b. - Model data tunggal: tipe
Tododiturunkan dari skema Zod (z.infer), bukan ditulis dua kali. Turunan sepertiTodoBarudibuat dengan utility types. - Data API tervalidasi: daftar tugas diambil dari
https://jsonplaceholder.typicode.com/todos?_limit=8, divalidasi dengan Zod, lalu dipetakan ke bentukTodomilik aplikasi. Tidak adaaspada hasilres.json(). - State bertipe:
useReducerdengan discriminated union untuk status (memuat/gagal/siap) dan untuk aksi (tambah,toggle,hapus, ...). - Form tambah tugas: validasi judul 3-80 karakter dan prioritas
rendah | sedang | tinggimemakai skema yang sama; pesan error berbahasa Indonesia. - Komponen bertipe: setiap komponen punya tipe props; minimal satu generic component (misalnya
FilterBar<T>). - Filter
semua | aktif | selesaidengan functionsaringyang menangani semua kasus (switch tanpa celah). - Masa transisi: setidaknya satu file
.jslama tetap berjalan bersama file TypeScript (denganallowJs+checkJsdan anotasi JSDoc), lalu dimigrasi di langkah terakhir. - Nol pelarian: tidak ada
any,// @ts-ignore, atauasyang membungkam error di kode akhir (pengecualian:document.getElementById("root")!dimain.tsx).
Langkah bertahap
Langkah 1, Siapkan fondasi. Kalau mulai dari proyek JavaScript Vite yang sudah ada, pasang TypeScript dan tipe React:
npm install -D typescript@latest @types/react @types/react-dom
npm install zod
npx tsc --version
# → Version 7.0.2
Cara termudah mendapatkan konfigurasi yang benar adalah menyalin tsconfig.json, tsconfig.app.json, dan tsconfig.node.json dari proyek baru npm create vite@latest contoh -- --template react-ts. Ganti vite.config.js menjadi vite.config.ts (isinya biasanya tidak perlu diubah) dan ubah skrip build:
{
"scripts": {
"dev": "vite",
"typecheck": "tsc -b",
"build": "tsc -b && vite build",
"preview": "vite preview"
}
}
Langkah 2, Izinkan JavaScript selama transisi. Tambahkan opsi berikut ke compilerOptions di tsconfig.app.json:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"allowJs": true,
"checkJs": true
}
}
allowJs membuat file .js/.jsx boleh diimpor dari file TypeScript. Tanpa opsi ini, tsc -b menolak dengan error:
npx tsc -b
# → src/App.tsx(9,31): error TS7016: Could not find a declaration file for module './lib/format'.
# '.../src/lib/format.js' implicitly has an 'any' type.
checkJs membuat TypeScript juga memeriksa file JavaScript dan membaca komentar JSDoc sebagai tipe. Kalau file JS lama terlalu banyak error, matikan checkJs sementara dan nyalakan lagi setelah sebagian besar file dimigrasi.
Langkah 3, Migrasi model data dulu. Buat src/lib/todo.ts: skema Zod, tipe turunan, function ambilTodos, dan saring. File ini tidak bergantung pada file lain, jadi aman dipindah paling awal, dan setelah itu semua file lain langsung mendapat tipe Todo yang benar.
Langkah 4, Ganti nama file satu per satu. Ubah useTodos.js menjadi useTodos.ts, lalu jalankan npm run typecheck. Error yang paling sering muncul pertama kali:
# → src/lib/lama.ts(1,31): error TS7006: Parameter 'selesai' implicitly has an 'any' type.
Perbaiki dengan memberi tipe parameter. Lanjutkan ke komponen: TodoItem.jsx → TodoItem.tsx, TodoForm.jsx → TodoForm.tsx, dan seterusnya. Commit setiap kali satu file selesai dan typecheck hijau, supaya mudah mundur kalau ada yang salah.
Langkah 5, Ketatkan state. Ganti state yang berupa beberapa boolean (loading, error, data) menjadi discriminated union. Ini biasanya perubahan dengan manfaat terbesar: keadaan mustahil (memuat sekaligus error) tidak bisa lagi terjadi.
Langkah 6, Validasi di pintu masuk. Pastikan satu-satunya tempat data mentah dari API disentuh adalah ambilTodos. Komponen hanya menerima Todo[] yang sudah tervalidasi.
Langkah 7, Migrasi file JavaScript terakhir. Ubah format.js menjadi format.ts, pindahkan tipe dari JSDoc ke anotasi TypeScript, lalu hapus allowJs dan checkJs dari tsconfig. Jalankan npm run build untuk memastikan semuanya bersih.
Langkah 8, Audit pelarian. Cari sisa jalan pintas:
grep -rnE "\bany\b|@ts-ignore|@ts-expect-error| as [A-Z]" src
Setiap hasil harus punya alasan yang kuat, atau diganti dengan narrowing, generics, atau validasi Zod.
Kode kerangka
Kerangka berikut sudah diuji pada proyek Vite react-ts (create-vite 9.3.1) dengan TypeScript 7.0.2, strict: true, dan noUncheckedIndexedAccess: true: tsc -b tanpa error dan vite build berhasil.
src/lib/todo.ts
// src/lib/todo.ts, satu sumber kebenaran: skema Zod + tipe turunan
import { z } from "zod";
export const PRIORITAS = ["rendah", "sedang", "tinggi"] as const;
export const SkemaTodo = z.object({
id: z.number().int(),
judul: z.string().trim().min(3, "Judul minimal 3 karakter").max(80, "Judul maksimal 80 karakter"),
selesai: z.boolean(),
prioritas: z.enum(PRIORITAS),
});
export type Todo = z.infer<typeof SkemaTodo>;
export type Prioritas = Todo["prioritas"];
export type TodoBaru = Omit<Todo, "id" | "selesai">;
export type Filter = "semua" | "aktif" | "selesai";
// Skema respons API jsonplaceholder (bentuknya beda dengan Todo kita)
const SkemaTodoApi = z.object({
id: z.number(),
title: z.string(),
completed: z.boolean(),
});
export async function ambilTodos(signal?: AbortSignal): Promise<Todo[]> {
const res = await fetch("https://jsonplaceholder.typicode.com/todos?_limit=8", { signal });
if (!res.ok) throw new Error(`Gagal memuat tugas (HTTP ${res.status})`);
const data = z.array(SkemaTodoApi).parse(await res.json());
return data.map((t) => ({ id: t.id, judul: t.title, selesai: t.completed, prioritas: "sedang" }));
}
export function saring(todos: Todo[], filter: Filter): Todo[] {
switch (filter) {
case "semua":
return todos;
case "aktif":
return todos.filter((t) => !t.selesai);
case "selesai":
return todos.filter((t) => t.selesai);
}
}
src/hooks/useTodos.ts
// src/hooks/useTodos.ts, state todo dengan useReducer bertipe
import { useEffect, useReducer } from "react";
import { ambilTodos } from "../lib/todo";
import type { Todo, TodoBaru } from "../lib/todo";
type State =
| { status: "memuat" }
| { status: "gagal"; pesan: string }
| { status: "siap"; todos: Todo[] };
type Aksi =
| { type: "dimuat"; todos: Todo[] }
| { type: "gagal"; pesan: string }
| { type: "tambah"; todo: TodoBaru }
| { type: "toggle"; id: number }
| { type: "hapus"; id: number };
function reducer(state: State, aksi: Aksi): State {
switch (aksi.type) {
case "dimuat":
return { status: "siap", todos: aksi.todos };
case "gagal":
return { status: "gagal", pesan: aksi.pesan };
}
// Aksi di bawah hanya masuk akal kalau data sudah siap
if (state.status !== "siap") return state;
switch (aksi.type) {
case "tambah": {
const idBaru = Math.max(0, ...state.todos.map((t) => t.id)) + 1;
return { ...state, todos: [{ ...aksi.todo, id: idBaru, selesai: false }, ...state.todos] };
}
case "toggle":
return { ...state, todos: state.todos.map((t) => (t.id === aksi.id ? { ...t, selesai: !t.selesai } : t)) };
case "hapus":
return { ...state, todos: state.todos.filter((t) => t.id !== aksi.id) };
}
}
export function useTodos() {
const [state, dispatch] = useReducer(reducer, { status: "memuat" });
useEffect(() => {
const controller = new AbortController();
ambilTodos(controller.signal)
.then((todos) => dispatch({ type: "dimuat", todos }))
.catch((err: unknown) => {
if (controller.signal.aborted) return;
dispatch({ type: "gagal", pesan: err instanceof Error ? err.message : "Terjadi kesalahan" });
});
return () => controller.abort();
}, []);
return {
state,
tambah: (todo: TodoBaru) => dispatch({ type: "tambah", todo }),
toggle: (id: number) => dispatch({ type: "toggle", id }),
hapus: (id: number) => dispatch({ type: "hapus", id }),
};
}
src/components/TodoForm.tsx
// src/components/TodoForm.tsx, form bertipe + validasi Zod
import { useState } from "react";
import type { ChangeEvent, SubmitEvent } from "react";
import { PRIORITAS, SkemaTodo } from "../lib/todo";
import type { Prioritas, TodoBaru } from "../lib/todo";
type TodoFormProps = { onTambah: (todo: TodoBaru) => void };
const SkemaForm = SkemaTodo.pick({ judul: true, prioritas: true });
export function TodoForm({ onTambah }: TodoFormProps) {
const [judul, setJudul] = useState("");
const [prioritas, setPrioritas] = useState<Prioritas>("sedang");
const [error, setError] = useState<string | null>(null);
function ubahPrioritas(e: ChangeEvent<HTMLSelectElement>) {
const hasil = SkemaForm.shape.prioritas.safeParse(e.target.value);
if (hasil.success) setPrioritas(hasil.data);
}
function kirim(e: SubmitEvent<HTMLFormElement>) {
e.preventDefault();
const hasil = SkemaForm.safeParse({ judul, prioritas });
if (!hasil.success) {
setError(hasil.error.issues[0]?.message ?? "Input tidak valid");
return;
}
onTambah(hasil.data);
setJudul("");
setError(null);
}
return (
<form onSubmit={kirim}>
<input value={judul} onChange={(e) => setJudul(e.target.value)} placeholder="Tugas baru" />
<select value={prioritas} onChange={ubahPrioritas}>
{PRIORITAS.map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
<button type="submit">Tambah</button>
{error && <p role="alert">{error}</p>}
</form>
);
}
src/components/TodoItem.tsx
// src/components/TodoItem.tsx
import type { Todo } from "../lib/todo";
type TodoItemProps = {
todo: Todo;
onToggle: (id: number) => void;
onHapus: (id: number) => void;
};
const WARNA: Record<Todo["prioritas"], string> = { rendah: "#16a34a", sedang: "#ca8a04", tinggi: "#dc2626" };
export function TodoItem({ todo, onToggle, onHapus }: TodoItemProps) {
return (
<li>
<label style={{ textDecoration: todo.selesai ? "line-through" : "none" }}>
<input type="checkbox" checked={todo.selesai} onChange={() => onToggle(todo.id)} />
{todo.judul}
</label>
<span style={{ color: WARNA[todo.prioritas] }}> ({todo.prioritas})</span>
<button onClick={() => onHapus(todo.id)} aria-label={`Hapus ${todo.judul}`}>×</button>
</li>
);
}
src/components/FilterBar.tsx
// src/components/FilterBar.tsx, generic component
type FilterBarProps<T extends string> = {
opsi: readonly T[];
aktif: T;
onPilih: (nilai: T) => void;
};
export function FilterBar<T extends string>({ opsi, aktif, onPilih }: FilterBarProps<T>) {
return (
<div role="group">
{opsi.map((o) => (
<button key={o} onClick={() => onPilih(o)} aria-pressed={o === aktif}>
{o}
</button>
))}
</div>
);
}
src/App.tsx
// src/App.tsx
import { useState } from "react";
import { FilterBar } from "./components/FilterBar";
import { TodoForm } from "./components/TodoForm";
import { TodoItem } from "./components/TodoItem";
import { useTodos } from "./hooks/useTodos";
import { saring } from "./lib/todo";
import type { Filter } from "./lib/todo";
import { formatProgres } from "./lib/format";
const FILTER = ["semua", "aktif", "selesai"] as const satisfies readonly Filter[];
export default function App() {
const { state, tambah, toggle, hapus } = useTodos();
const [filter, setFilter] = useState<Filter>("semua");
if (state.status === "memuat") return <p>Memuat tugas...</p>;
if (state.status === "gagal") return <p role="alert">Error: {state.pesan}</p>;
const tampil = saring(state.todos, filter);
const selesai = state.todos.filter((t) => t.selesai).length;
return (
<main>
<h1>Daftar Tugas</h1>
<p>{formatProgres(selesai, state.todos.length)}</p>
<TodoForm onTambah={tambah} />
<FilterBar opsi={FILTER} aktif={filter} onPilih={setFilter} />
<ul>
{tampil.map((t) => (
<TodoItem key={t.id} todo={t} onToggle={toggle} onHapus={hapus} />
))}
</ul>
</main>
);
}
File JavaScript lama yang dibiarkan selama transisi. Perhatikan komentar JSDoc: dengan checkJs, TypeScript memakainya sebagai tipe, sehingga formatProgres(String(selesai), total) dari App.tsx akan ditolak dengan error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
src/lib/format.js
// src/lib/format.js, file lama yang BELUM dimigrasi
/**
* @param {number} selesai
* @param {number} total
* @returns {string}
*/
export function formatProgres(selesai, total) {
if (total === 0) return "Belum ada tugas";
return `${selesai}/${total} selesai (${Math.round((selesai / total) * 100)}%)`;
}
Beberapa hal yang perlu kamu perhatikan dari kerangka ini:
reducerdiuseTodos.tsmemakai duaswitch. Yang pertama menangani aksi yang boleh terjadi kapan saja (dimuat,gagal). Setelahif (state.status !== "siap") return state;, TypeScript tahustate.todostersedia, dan sisa aksi diswitchkedua sudah dipersempit menjaditambah | toggle | hapus. Karena semua kasus tertangani, function tidak butuhreturndi akhir.TodoFormmemakai skema yang sama dengan model data (SkemaTodo.pick(...)), sehingga aturan "judul 3-80 karakter" hanya ditulis sekali. Nilai<select>divalidasi denganSkemaForm.shape.prioritas.safeParse, bukanas Prioritas.FilterBar<T extends string>adalah generic component: dipakai denganopsi={FILTER}, TypeScript menyimpulkanTsebagai"semua" | "aktif" | "selesai", sehinggaonPilih={setFilter}cocok tanpa assertion.as const satisfies readonly Filter[]padaFILTERmenjaga tipe literal sekaligus memastikan setiap nilai benar-benarFilter.- Mapping API: jsonplaceholder memakai
title/completed(bahasa Inggris), aplikasi memakaijudul/selesai. Skema terpisahSkemaTodoApimemvalidasi bentuk API, lalumapmengubahnya. Kalau besok API berubah, error muncul di satu tempat dengan pesan jelas.
Kriteria lulus
- [ ]
npm run typecheck(tsc -b) selesai tanpa error dengan TypeScript 7.x. - [ ]
npm run buildberhasil dan aplikasi berjalan dinpm run preview. - [ ]
tsconfig.app.jsonberisistrict: truedannoUncheckedIndexedAccess: true;allowJssudah dihapus di akhir migrasi. - [ ] Tipe
Tododiturunkan dari skema Zod denganz.infer, tidak ada definisi tipeTodokedua. - [ ] Hasil
res.json()divalidasi dengan Zod; tidak adaas Todo[]atau sejenisnya. - [ ] State daftar tugas memakai discriminated union (bukan kombinasi
loading/error/dataterpisah). - [ ] Form menolak judul kurang dari 3 karakter dengan pesan "Judul minimal 3 karakter".
- [ ] Setiap komponen punya tipe props, minimal satu generic component, dan handler event bertipe (
ChangeEvent,SubmitEvent). - [ ] Perintah audit
grep -rnE "\bany\b|@ts-ignore|@ts-expect-error| as [A-Z]" srctidak menemukan pelarian tanpa alasan. - [ ] Riwayat commit menunjukkan migrasi bertahap (minimal satu commit per kelompok file).
Pengembangan lanjutan
- Simpan ke
localStoragedengan validasi Zod saat membaca, seperti latihan di modul Data API. Data rusak di penyimpanan tidak boleh membuat aplikasi crash. - Ganti
useEffect+useReduceruntuk data server dengan TanStack Query (useQuery,useMutation) dan amati bagaimana tipedatamengalir otomatis dariqueryFn. - Gunakan React Hook Form +
@hookform/resolvers/zoduntuk form tambah tugas, memakaiz.inputuntuk tipe nilai form. - Tambahkan pemeriksaan di CI: workflow GitHub Actions yang menjalankan
npm ci && npm run typecheckdi setiap pull request. - Ketatkan lagi: nyalakan
exactOptionalPropertyTypesdannoImplicitOverride, lalu perbaiki error yang muncul. Catat berapa bug yang tertangkap. - Bersiap ke Next.js: pindahkan
lib/todo.tsapa adanya ke proyek Next.js di materi berikutnya. Skema dan tipe yang sama bisa dipakai di Server Component, Route Handler, dan Server Action.