ข้ามไปยังเนื้อหา

TypeScript with Svelte

เพิ่ม lang="ts" ให้ script แล้วคุณก็เขียน TypeScript ได้ rune เป็น generic อยู่แล้ว state จึงถูก type โดยไม่ต้องมีพิธีรีตอง:

<script lang="ts">
let count = $state(0); // inferred number
let name = $state<string>(''); // explicit
let user = $state<User | null>(null);
</script>

compiler infer type จากค่าเริ่มต้นเมื่อทำได้ ใช้ $state<T>(...) เมื่ออยาก explicit (เช่น ค่าเริ่มต้นเป็น null แต่ type กว้างกว่านั้น)

ประกาศ interface สำหรับ props แล้ว annotate การ destructure $props() นี่คือวิธี idiomatic ของ Svelte 5 ที่มาแทน props แบบ export let ที่ type ทีละตัวใน Svelte 4:

<script lang="ts">
interface Props {
label: string;
count?: number; // optional
onclick?: () => void; // event callbacks are just props
}
let { label, count = 0, onclick }: Props = $props();
</script>
<button {onclick}>{label}: {count}</button>

ผู้ใช้ component จะได้ autocompletion และ type-checking ตอนใช้ และ svelte-check จะ flag prop ที่ผิดหรือขาดตรงจุดที่เรียกใช้

snippet ที่ส่งเป็น prop ถูก type ด้วย type Snippet จาก svelte snippet ที่รับ parameter เป็น Snippet<[ArgType]>:

<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
header: Snippet; // a plain snippet
row: Snippet<[item: string]>; // a snippet taking one argument
children: Snippet; // the default passed-in content
}
let { header, row, children }: Props = $props();
</script>
{@render header()}
{@render row('first')}
{@render children()}

เมื่อ type ของ component ขึ้นกับสิ่งที่ผู้ใช้ส่งเข้ามา (list ที่ type แล้ว, select แบบ generic) ให้ประกาศ type parameter ด้วย attribute generics บน script tag:

<script lang="ts" generics="T">
interface Props {
items: T[];
selected: T;
onselect: (item: T) => void;
}
let { items, selected, onselect }: Props = $props();
</script>

ตอนนี้ T ไหลผ่านทั้งหมด: ส่ง items เป็น User[] แล้ว onselect จะถูก type เป็น (item: User) => void มี type Component (จาก svelte) สำหรับ type ตัวแปรที่เก็บ component ด้วย

“T ไหลผ่าน” เป็น claim ที่ตรวจสอบได้เจาะจง — ดังนั้นตรวจมันเลย นี่คือ generic component จากด้านบน และ caller สองแบบ: แบบที่ type ของ parameter ใน callback ถูกต้อง กับแบบที่ผิด

src/lib/Select.svelte
<script lang="ts" generics="T">
interface Props {
items: T[];
selected: T;
onselect: (item: T) => void;
}
let { items, selected, onselect }: Props = $props();
</script>
<ul>
{#each items as item}
<li class:active={item === selected}>
<button onclick={() => onselect(item)}>{JSON.stringify(item)}</button>
</li>
{/each}
</ul>
src/lib/UseSelectGood.svelte
<script lang="ts">
import Select from './Select.svelte';
interface User { id: number; name: string }
let users: User[] = [{ id: 1, name: 'Ada' }];
let picked = $state<User>(users[0]);
</script>
<!-- T is inferred as User: onselect is typed (item: User) => void -->
<Select items={users} selected={picked} onselect={(u) => { picked = u; console.log(u.name); }} />
src/lib/UseSelectBad.svelte
<!-- @expect-error Property 'toFixed' does not exist on type 'User'. -->
<script lang="ts">
import Select from './Select.svelte';
interface User { id: number; name: string }
let users: User[] = [{ id: 1, name: 'Ada' }];
let picked = $state<User>(users[0]);
</script>
<!-- BUG: treats the callback's item as a number, but T was inferred as User -->
<Select items={users} selected={picked} onselect={(u) => console.log(u.toFixed(2))} />

output จริงของ svelte-check สำหรับ UseSelectBad.svelte — หลักฐานว่า T ถูก infer เป็น User จริง ๆ ไม่ได้ปล่อยเป็น unknown หรือยอมให้เป็นอะไรก็ได้แบบเงียบ ๆ:

src/lib/UseSelectBad.svelte:8:72
Property 'toFixed' does not exist on type 'User'.

.toFixed เป็น method ของ number; การที่ checker ปฏิเสธมันบน u คือหลักฐานตรง ๆ ว่า type ของ u มาจาก items: User[] ไหลผ่าน T แล้วไปลงที่ parameter ของ onselect — ไม่ใช่เรื่องบังเอิญและไม่ใช่ any

flowchart LR
  props["interface Props"] --> dollar["let { ... }: Props = props()"]
  snip["Snippet type"] --> dollar
  gen["generics attribute (T)"] --> dollar
  dollar --> check["svelte-check verifies usage + markup"]
type ไหลไปที่ไหนใน Svelte component

TypeScript ในไฟล์ .svelte ถูกตรวจสองรอบที่ต้องสอดคล้องกัน: block <script lang="ts"> เป็น TypeScript ธรรมดา (อะไรก็ตามที่ tsc จะพูดถึงมัน) และ markup ถูกตรวจโดยความเข้าใจของ svelte-check เองต่อ template syntax ของ Svelte — {expression}, prop ที่ส่งบน component tag, type ของ item ใน {#each} ที่ infer มา generics="T" คือสิ่งที่ทำให้ type parameter ตัวเดียวไหลข้ามขอบเขตนั้นได้: ประกาศครั้งเดียวบน tag <script> แล้วใช้ใน interface Props และ infer ที่ทุก call site จากสิ่งที่ caller ส่งเข้ามาจริง — พิสูจน์แล้วด้านบนว่า T ไม่ใช่แค่พิธีการ checker ไหลมันไปถึง prop ที่ห่างออกไปหลายชั้นจากจุดที่มัน infer จริง ๆ

ได้อะไรประโยชน์ต้นทุน
interface Props + $props()รูปร่าง type เดียวสำหรับพื้นผิวทั้งหมดของ component ตรวจที่ทุก call site (พิสูจน์แล้วด้านบนด้วย error จริงของ prop type)ต้องประกาศ interface เพิ่มต่อ component เทียบกับ export let ที่ไม่มี type
generics="T"type ของผู้ใช้ไหลผ่านอัตโนมัติ — ไม่ต้อง any ไม่ต้อง cast ต่อการใช้งาน (พิสูจน์แล้วด้านบน)ergonomics แค่ระดับ type parameter เดียว; component ที่ generic เหนือรูปร่างที่ไม่เกี่ยวกันหลายแบบจริง ๆ ต้องดูแล interface เองมากกว่านี้
Snippet<[Arg]> สำหรับ typed slot contentrenderer กับ body ของ snippet ใช้ parameter type เดียวกัน ตรวจโดย svelte-checkมีพิธีการมากกว่า Snippet ที่ไม่ type เล็กน้อยเมื่อเนื้อหาไม่รับ argument จริง ๆ
  • “type parameter ของ generic component เป็นแค่ documentation — ไม่ได้ถูกตรวจจริง” พิสูจน์แล้วด้านบน: error จริงของ svelte-check ใน UseSelectBad.svelte (Property 'toFixed' does not exist on type 'User') จะเกิดขึ้นได้ก็ต่อเมื่อ T ถูก infer เป็น User จริง ๆ จาก items แล้วพาไปถึง type ของ parameter ใน onselect
  • “ต้องใช้ as any ที่ไหนสักแห่งเพื่อให้ callback prop ของ generic component ใน Svelte เข้ากันได้” ไม่ใช่ในตัวอย่างที่พิสูจน์แล้วด้านบน — onselect={(u) => { picked = u; console.log(u.name); }} type-check ผ่านสะอาด โดย u ถูก type เป็น User ถูกต้อง ไม่ต้อง cast เลย
  • “type props มีไว้แค่จับ typo ใน component ของตัวเองเท่านั้น” error ที่พิสูจน์แล้วด้านบนถูกจับที่ markup ของ caller (UseSelectBad.svelte ไม่ใช่ Select.svelte) — คุณค่าที่ใหญ่ที่สุดคือการจับการใช้ผิดที่ไหนก็ตามที่ component ถูกใช้ ไม่ใช่แค่ข้างในไฟล์ของมันเอง

เวลารีวิว typed Svelte component ให้เช็ค:

  • component ที่ “generic” ประกาศ generics="T" บน tag <script> จริงหรือเปล่า หรือแค่ใช้ T ใน interface Props โดยไม่เคย introduce มันเลย — ซึ่ง compile ไม่ผ่าน แทนที่จะกลายเป็น any แบบเงียบ ๆ?
  • prop หรือ callback parameter ถูก type เป็น any (หรือไม่ type เลย ทำให้ infer เป็น any โดยปริยาย) เพื่อทำให้ type error หายไปโดยเฉพาะ แทนที่จะแก้ mismatch จริงที่ svelte-check รายงานหรือเปล่า?
  • type Snippet ละ parameter ไว้ (Snippet เปล่า ๆ แทน Snippet<[Arg]>) ทั้งที่ snippet นั้นถูกเรียกพร้อม argument เสมอในทางปฏิบัติ — ทำให้ call site ของ {@render row(item)} ไม่ถูก type-check หรือเปล่า?
  • generic ที่ explicit ของ $state<T>(...) ถูกใช้เพื่อกลบ mismatch ของค่าเริ่มต้น แทนที่จะแก้ค่านั้นหรือขยาย type อย่างซื่อตรง (เช่น T | null) หรือเปล่า?

🐛 หา bug ในโค้ดนี้

<script lang="ts" generics="T">
interface Props {
items: T[];
onselect: (item: any) => void;
}
let { items, onselect }: Props = $props();
</script>
<ul>
{#each items as item}
<li><button onclick={() => onselect(item)}>{JSON.stringify(item)}</button></li>
{/each}
</ul>
เฉลย

items เป็น generic เหนือ T ถูกต้อง แต่ parameter ของ onselect ถูก type เป็น any แทนที่จะเป็น T — ดังนั้นการเชื่อมต่อจุดเดียวที่สำคัญ (callback รับ item type เดียวกันเป๊ะกับที่ caller ใส่เข้าไปใน items) ถูกตัดขาดแบบเงียบ ๆ caller ตอนนี้ส่ง onselect={(x: number) => x.toFixed(2)} ได้แม้ items เป็น User[] และไม่มีอะไรจับเลย: any ปิดการตรวจสำหรับ parameter นั้นทั้งหมด ซึ่งทำลายจุดประสงค์ทั้งหมดของการทำให้ component เป็น generic ตั้งแต่แรก (พิสูจน์แล้วก่อนหน้าในบทนี้: ด้วย onselect: (item: T) => void ความผิดพลาดแบบเดียวกันเป็น error จริงที่ svelte-check จับได้)

วิธีแก้คือ type แบบเดียวกับที่บทนี้ใช้ตลอด: onselect: (item: T) => void — type parameter ตัวเดียวกับที่ Props ที่เหลือใช้อยู่แล้ว ไม่ใช่ escape hatch ใหม่

จะ type state ที่ค่าเริ่มต้นไม่บอก type เต็มได้อย่างไร?
วิธี idiomatic ในการ type props ใน Svelte 5 คืออะไร?
type อะไรอธิบาย snippet ที่ส่งเป็น prop?
จะเขียน generic component ที่ type ขึ้นกับ data ของผู้ใช้ได้อย่างไร?
ถ้า users เป็น User[] และ prop onselect ของ Select ถูก type เป็น (item: T) => void svelte-check รายงานอะไร?
<Select items={users} selected={picked}
  onselect={(u) => console.log(u.toFixed(2))} />
generic component type prop onselect เป็น (item: any) => void แทนที่จะเป็น (item: T) => void ผลที่ตามมาคืออะไร?
interface Props {
  items: T[];
  onselect: (item: any) => void;
}