Selama berminggu-minggu next dev lancar tanpa satu pun coretan merah. Saya kerja di komponen Reveal untuk situs klien: sebuah pembungkus animasi yang menyapu isinya masuk saat masuk viewport. Karena kadang ingin dia jadi section, kadang div, kadang article, saya bikin dia polimorfik lewat prop as. Local mulus, saya push, dan Vercel menampar saya dengan build merah di tahap type-check.
Pesannya kira-kira begini:
Type error: Type '{ children: ReactNode; ... }' is not assignable to type 'IntrinsicAttributes'.
Property 'children' does not exist on type 'IntrinsicAttributes'.
Yang lebih membingungkan: pas saya hover children di editor, TypeScript bilang tipenya never. children yang saya oper jelas-jelas ada, tapi kompiler yakin tempat itu tidak boleh diisi apa pun. Dan yang paling menyebalkan, ini cuma muncul di Vercel, tidak pernah di mesin saya.
Kenapa cuma pecah di build
Ini jebakan pertama yang harus saya cerna: next dev tidak menjalankan type-check penuh untuk seluruh proyek. Dev server itu sengaja permisif demi kecepatan. Dia meng-compile per rute yang sedang kamu buka, dan tidak menjalankan tsc menyeluruh. Jadi error tipe di file yang belum kamu sentuh selama sesi dev bisa duduk diam berminggu-minggu.
next build beda cerita. Di build dia menjalankan tsc untuk seluruh proyek. Di situlah error yang selama ini tersembunyi baru muncul. Jadi ini bukan bug yang "muncul di produksi" dalam arti runtime, dia sudah ada dari awal, cuma dev server tidak pernah menunjukkannya. Pelajaran pertama sudah jelas: kalau kamu ingin tahu apa yang akan dilihat CI, jalankan tsc --noEmit lokal, jangan cuma percaya dev server yang hijau.
Akar masalahnya: elemen dinamis kehilangan jaminan children
Sekarang ke inti tipenya. Komponen Reveal saya kurang lebih begini:
type RevealProps = {
as?: ElementType
children?: ReactNode
className?: string
}
function Reveal({ as, children, className }: RevealProps) {
const Tag = as ?? 'div'
const ref = useRef<HTMLDivElement>(null)
// ... IntersectionObserver di useEffect ...
return (
<Tag ref={ref} className={className}>
{children}
</Tag>
)
}Masalahnya ada di const Tag = as. as diketik sebagai ElementType, yang berarti "elemen apa pun yang mungkin". Karena ElementType itu union yang sangat lebar, TypeScript tidak bisa menjamin bahwa tag arbitrer mana pun menerima children, apalagi ref. Ada ElementType yang memang tidak menerima children. Jadi ketika saya render Tag dan mengoper children plus ref, TS menyempitkan tipe children ke never, sebagai cara dia bilang "aku tidak bisa membuktikan slot ini boleh diisi". Bukan children-nya yang salah, tapi kompiler yang menolak menjamin Tag menerimanya.
Dev server tidak pernah mengeksekusi pengecekan itu untuk file Reveal.tsx selama sesi saya, makanya diam. tsc di build mengeksekusinya, makanya meledak.
Perbaikannya
Solusinya bukan membuang prop as yang publik, itu tetap harus diketik ElementType supaya konsumen komponen bebas mengoper tag apa pun. Yang saya lakukan adalah membuat alias internal yang secara eksplisit mendeklarasikan prop yang benar-benar saya teruskan ke elemen, lalu cast tag dinamis ke alias itu:
const TagBase = as ?? 'div'
const Tag = TagBase as unknown as ComponentType<{
ref?: Ref<HTMLDivElement>
children?: ReactNode
className?: string
}>
return (
<Tag ref={ref} className={className}>
{children}
</Tag>
)Cast as unknown as di sini disengaja. Saya memberi tahu TypeScript: "perlakukan tag yang sudah teresolusi ini sebagai komponen yang memang menerima ref, children, dan className." Prop as yang publik tetap ElementType, jadi API komponen tidak berubah untuk pemanggil. Yang berubah cuma jaminan internal tepat di titik render. Setelah perubahan itu, tsc --noEmit keluar dengan kode 0, dan build Vercel hijau lagi.
Perhatikan as unknown as yang dobel, bukan cast langsung. ElementType dan ComponentType tidak cukup tumpang-tindih untuk di-cast langsung, TS akan menolak. Melewati unknown dulu adalah cara eksplisit untuk bilang "aku tahu apa yang aku lakukan di titik ini".
Checklist
- Jangan percaya
next devyang hijau sebagai bukti build lolos. Dev server melewati type-check menyeluruh;next buildmenjalankantscpenuh. - Jalankan
tsc --noEmitlokal sebelum push, dan idealnya jadikan langkah di CI, supaya error tipe muncul sebelum Vercel yang menemukannya. - Untuk komponen polimorfik, biarkan prop
aspublik tetapElementType, tapi cast tag yang sudah teresolusi keComponentTypeyang secara eksplisit mendeklarasikanrefdanchildrensebelum render. - Kalau
childrentiba-tiba diketikneverpadahal jelas ada, curigai elemen dinamis atau polimorfik: kompiler tidak bisa membuktikan slot itu menerima children, bukan kamu yang salah oper.
Sejak itu saya berhenti menganggap dev server hijau sebagai lampu hijau untuk push. Yang benar-benar diverifikasi CI adalah tsc, jadi itu yang saya jalankan dulu.
