กลับไปยังบทความ

Frontend

TypeScript any vs unknown, void และ never: เข้าใจ Type พื้นฐานให้ถูกตั้งแต่ต้น

เข้าใจ any vs unknown ใน TypeScript พร้อมตัวอย่าง ครอบคลุม primitive และ literal type, strictNullChecks, void กับ never, exhaustive check, type assertion, satisfies และ as const

24 กันยายน 256910 นาทีในการอ่าน

typescript basic types any unknown never

เรื่อง any vs unknown ใน TypeScript สรุปได้ด้วยคำถามเดียว คือ compiler ยังตรวจสิ่งที่คุณทำกับค่านั้นอยู่หรือไม่ ทั้งสอง type รับค่าอะไรก็ได้เหมือนกัน แต่ any ปิดการตรวจ type ไปเลย ทุกการเข้าถึง property และทุกการเรียกเมธอดจะ compile ผ่าน แม้แต่อันที่พังตอน runtime ส่วน unknown บังคับให้คุณพิสูจน์ก่อนว่าค่านั้นเป็นอะไร ด้วยการเช็กอย่าง typeof หรือ instanceof แล้วจึงใช้งานได้ ค่าที่ยังไม่รู้ type ควรใช้ unknown

ความต่างนี้ตั้งอยู่บนพื้นฐานอีกไม่กี่เรื่องที่ควรเข้าใจให้ถูกตั้งแต่ต้น ได้แก่ primitive type และ literal type, ผลของ strictNullChecks ต่อ null และ undefined, ความต่างระหว่าง void กับ never และจังหวะที่ควรใช้ type assertion กับ as const บทความนี้อธิบายทีละเรื่องพร้อมตัวอย่างบน TypeScript 5.x

Primitive type ใน TypeScript

TypeScript มี primitive ชุดเดียวกับ JavaScript:

Typeตัวอย่างค่าหมายเหตุ
string"hello", `id-${n}`
number42, 3.14, NaNจำนวนเต็มและทศนิยมใช้ type เดียวกัน
bigint9007199254740993nต้องตั้ง target เป็น ES2020 ขึ้นไป
booleantrue, false
symbolSymbol("id")ใช้เป็น key ที่ไม่ซ้ำ
nullnullตั้งใจให้ไม่มีค่า
undefinedundefinedยังไม่ได้กำหนดค่า

ให้ใช้ชื่อตัวพิมพ์เล็ก String, Number และ Boolean เป็น type ของ wrapper object ซึ่งแทบไม่เคยเป็นสิ่งที่คุณต้องการ

ตัวแปร local ส่วนใหญ่ไม่ต้องใส่ annotation เพราะ TypeScript infer ให้เอง สิ่งที่ควรระบุคือ parameter ของฟังก์ชัน, return type ของฟังก์ชันที่ export และตัวแปรที่เริ่มต้นด้วยค่าว่าง:

ts
let count = 0;                 // inferred as number
const ids: string[] = [];      // empty array needs an annotation
function total(prices: number[]): number {
  return prices.reduce((sum, p) => sum + p, 0);
}

Literal type: let กับ const infer ต่างกันอย่างไร

Literal type คือ type ที่มีค่าได้ค่าเดียว เช่น "GET" หรือ 404 keyword ที่ใช้ประกาศตัวแปรเป็นตัวตัดสินว่า TypeScript จะเก็บ literal ไว้หรือขยาย (widen) เป็น type กว้าง:

ts
let a = "GET";     // string: a let can be reassigned
const b = "GET";   // "GET": a const can never change
var c = 404;       // number

Literal type จะมีประโยชน์จริงเมื่ออยู่ใน union ซึ่งทำหน้าที่เหมือน enum แบบเบา ๆ:

ts
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";

function request(url: string, method: HttpMethod) { /* ... */ }

request("/users", "GET");   // OK
request("/users", "FETCH"); // Error: '"FETCH"' is not assignable to parameter of type 'HttpMethod'

Union ของ literal รวมถึง discriminated union และ template literal type มีอธิบายละเอียดใน TypeScript advanced types

strictNullChecks: ทำให้ null และ undefined ชัดเจน

ถ้าไม่เปิด strictNullChecks ค่า null และ undefined จะ assign ให้ type ไหนก็ได้ string จึงแอบหมายถึง "string หรืออาจไม่มีอะไรเลย" พอเปิดแล้ว คุณต้องเขียนให้ชัดเอง:

json
{
  "compilerOptions": {
    "strict": true
  }
}

"strict": true เปิด strictNullChecks พร้อมกับ noImplicitAny และการตรวจอื่น ๆ อีกหลายตัว ควรเปิดในทุกโปรเจกต์ใหม่ ดูการตั้งค่า tsconfig.json แบบครบได้ใน คู่มือตั้งค่าโปรเจกต์ TypeScript

เมื่อเปิด strict null checks แล้ว compiler จะจับ crash คลาสสิกแบบ "cannot read properties of undefined" ได้ตั้งแต่ตอนเขียน:

ts
interface User { id: number; name: string }

function findUser(id: number): User | undefined {
  return users.find((u) => u.id === id);
}

const user = findUser(1);
console.log(user.name);            // Error: 'user' is possibly 'undefined'
console.log(user?.name);           // OK: string | undefined
const name = user?.name ?? "Guest"; // OK: string

Optional chaining (?.) จะหยุดเมื่อเจอ null หรือ undefined และ nullish coalescing (??) ใช้ใส่ค่า fallback ส่วน non-null assertion user!.name ก็ compile ผ่านเช่นกัน แต่มันแค่ปิด error ไว้ ใช้เฉพาะตอนที่คุณรู้บางอย่างที่ compiler ไม่รู้จริง ๆ

any vs unknown ใน TypeScript

ทั้งสอง type รับค่าอะไรก็ได้ ความต่างอยู่ที่สิ่งที่ทำได้กับค่านั้นหลังจากรับมาแล้ว

ts
let a: any = "hello";
a.toFixed(2);          // compiles, then throws: a.toFixed is not a function
const n: number = a;   // compiles: any is assignable to everything

let u: unknown = "hello";
u.toFixed(2);          // Error: 'u' is of type 'unknown'
const m: number = u;   // Error: 'unknown' is not assignable to 'number'

if (typeof u === "string") {
  console.log(u.toUpperCase()); // OK: narrowed to string
}
anyunknown
รับค่าได้ทุกแบบได้ได้
เข้าถึง property และเรียกเมธอดทำได้ ไม่มีการตรวจerror จนกว่าจะ narrow
Assign ให้ type อื่นได้ทุก typeได้แค่ unknown และ any
ลามไปทั่วโค้ดลาม ผลลัพธ์ก็เป็น any ด้วยไม่ลาม
ใช้เมื่อไรย้ายโค้ดจาก JavaScript, ทางหนีฉุกเฉินinput จากภายนอก: JSON, API response, error ใน catch

การลามคืออันตรายที่แท้จริงของ any เพราะ a.user.profile ก็เป็น any และทุกอย่างที่คำนวณต่อจากมันก็เป็น any เช่นกัน any ตัวเดียวจึงปิดการตรวจ type ในจุดที่ห่างออกไปไกลได้โดยไม่มีใครรู้ตัว

Narrow ค่า unknown อย่างปลอดภัย

การทำให้ unknown ใช้งานได้คือการ narrow มัน ด้วย typeof, instanceof, Array.isArray, operator in หรือฟังก์ชัน type guard

JSON.parse คืนค่าเป็น any นิสัยที่ดีคือรับผลลัพธ์เป็น unknown แล้ว validate ก่อนใช้:

ts
interface Config {
  port: number;
  host: string;
}

function isConfig(value: unknown): value is Config {
  return (
    typeof value === "object" &&
    value !== null &&
    "port" in value && typeof value.port === "number" &&
    "host" in value && typeof value.host === "string"
  );
}

const raw: unknown = JSON.parse(text);
if (!isConfig(raw)) {
  throw new Error("Invalid config file");
}
console.log(raw.port); // raw is Config here

ถ้า payload ใหญ่ขึ้น library สำหรับ schema อย่าง Zod หรือ Valibot ทำงานเดียวกันได้ด้วยโค้ดที่น้อยกว่า ส่วน type guard และเทคนิค narrowing อื่น ๆ อยู่ในบทความ interface vs type alias

อีกกรณีที่เจอบ่อยคือ error ใน catch เมื่อเปิด strict ออปชัน useUnknownInCatchVariables จะให้ค่าที่ catch ได้เป็น unknown เพราะ JavaScript ยอมให้ throw อะไรก็ได้:

ts
try {
  await saveOrder(order);
} catch (err) {
  const message = err instanceof Error ? err.message : String(err);
  logger.error(message);
}

เมื่อไรที่ใช้ any ได้

any ยังมีที่ใช้อยู่ เช่น ตอนค่อย ๆ ย้าย codebase จาก JavaScript หรือตอนต้องอ้อม type ของ third-party ที่ผิด ให้จำกัดวงไว้แคบ ๆ เขียน comment บอกเหตุผล และเปิด lint rule @typescript-eslint/no-explicit-any เพื่อให้ any ตัวใหม่ต้องผ่านการ review

void vs never

ทั้งสองใช้กับฟังก์ชันที่ไม่ได้ให้ค่าที่ใช้ประโยชน์ได้ แต่ด้วยเหตุผลต่างกัน

  • void หมายถึงฟังก์ชัน return กลับมา แต่ไม่ควรนำค่าที่ return ไปใช้ ตอน runtime มันคืน undefined
  • never หมายถึงฟังก์ชันไม่เคย return ตามปกติเลย ต้อง throw หรือวนไม่รู้จบเสมอ
ts
function log(message: string): void {
  console.log(message);
}

function fail(message: string): never {
  throw new Error(message);
}

never ยังเป็น type ของค่าที่เป็นไปไม่ได้ จึงเหมาะกับการตรวจว่าครอบคลุมทุกกรณี (exhaustive check) ถ้ามีคนเพิ่มสมาชิกใหม่ใน union แล้วลืมเพิ่ม case บรรทัดที่ assign ให้ never จะ compile ไม่ผ่าน:

ts
type Status = "active" | "suspended" | "deleted";

function statusLabel(status: Status): string {
  switch (status) {
    case "active":
      return "Active";
    case "suspended":
      return "Suspended";
    case "deleted":
      return "Deleted";
    default: {
      const unreachable: never = status;
      throw new Error(`Unhandled status: ${unreachable}`);
    }
  }
}

มีรายละเอียดของ void ที่หลายคนงง คือ function type ที่ return void ยอมรับฟังก์ชันที่ return ค่าได้ นี่คือเหตุผลที่ items.forEach((x) => results.push(x)) compile ผ่าน แม้ push จะคืนค่าเป็น number ค่านั้นแค่ถูกทิ้งไป

Type assertion: บอก compiler ในสิ่งที่คุณรู้

Type assertion ด้วย as บอกให้ TypeScript มองค่าหนึ่งเป็น type ที่เจาะจงขึ้น มันไม่ทำอะไรเลยตอน runtime ไม่มีการแปลงค่าและไม่มีการตรวจ

ts
const input = document.getElementById("email") as HTMLInputElement;
input.value = "[email protected]";

getElementById คืนค่า HTMLElement | null การ assert แบบนี้เท่ากับบอกว่า "ฉันรู้ว่านี่คือ input และมันมีอยู่จริง" ถ้าคุณเดาผิด error จะไปโผล่ตอน runtime แทน เวอร์ชันที่ปลอดภัยกว่ายังเก็บการเช็ก null ไว้:

ts
const input = document.querySelector<HTMLInputElement>("#email");
if (input) input.value = "[email protected]";

TypeScript ยอมให้ assert ได้เฉพาะระหว่าง type ที่ทับซ้อนกัน "hello" as number จะ error การเขียน value as unknown as Target อ้อมข้อจำกัดนี้ได้ แต่แทบทุกครั้งเป็นสัญญาณว่าควรแก้ที่ตัว type มากกว่า

ถ้าต้องการตรวจว่าค่าตรงกับ type โดยไม่เสีย type ที่ infer ได้อย่างละเอียด ให้ใช้ satisfies:

ts
type Route = { path: string; auth: boolean };

const routes = {
  home: { path: "/", auth: false },
  admin: { path: "/admin", auth: true },
} satisfies Record<string, Route>;

routes.admin.path; // OK: TypeScript still knows the keys are "home" | "admin"

ถ้าใช้ annotation แทน (const routes: Record<string, Route>) ชื่อ key จะหายไป และ routes.anything ก็ compile ผ่าน

Const assertion ด้วย as const

as const สั่งให้ TypeScript infer type ที่แคบที่สุดเท่าที่ทำได้ คือได้ค่า literal แทน string หรือ number และได้ property กับ tuple แบบ readonly แทน object และ array ที่แก้ไขได้

ts
const req = { url: "/users", method: "GET" };
request(req.url, req.method);
// Error: 'string' is not assignable to parameter of type 'HttpMethod'

const req2 = { url: "/users", method: "GET" } as const;
request(req2.url, req2.method); // OK: method is "GET"

รูปแบบที่มีประโยชน์ที่สุดคือการสร้าง union type จากรายการค่า ทำให้ array ตอน runtime กับ type ไม่มีทางไม่ตรงกัน:

ts
const ROLES = ["admin", "editor", "viewer"] as const;
type Role = (typeof ROLES)[number]; // "admin" | "editor" | "viewer"

function isRole(value: string): value is Role {
  return (ROLES as readonly string[]).includes(value);
}

Object ที่ใส่ as const ยังเป็นทางเลือกยอดนิยมแทน enum ด้วย

คำถามที่พบบ่อย

ควรใช้ unknown หรือ any?

ใช้ unknown กับค่าที่ยังไม่รู้ type เช่น JSON ที่ parse มา, API response และ error ที่ catch ได้ มันบังคับให้ตรวจก่อนใช้ เก็บ any ไว้สำหรับการย้ายโค้ดและการอ้อมปัญหาเฉพาะจุด

void ต่างจาก undefined อย่างไร?

undefined เป็นทั้งค่าและ type ที่ assign และเช็กได้ ส่วน void ใช้กับ return type และหมายถึง "อย่าใช้ค่าที่ return" ฟังก์ชันที่ return void คืน undefined จริงตอน runtime แต่ type ช่วยสื่อเจตนาให้ชัด

Type assertion คือ type casting หรือเปล่า?

ไม่ใช่ casting ในภาษาอย่าง Java หรือ C# อาจแปลงหรือตรวจค่าตอน runtime แต่ assertion ของ TypeScript ถูกลบทิ้งตอน compile และเปลี่ยนแค่สิ่งที่ compiler เชื่อ

as const ใน TypeScript ทำอะไร?

ทำให้ TypeScript infer เป็น literal type และ property แบบ readonly เช่น ["a", "b"] as const มี type เป็น readonly ["a", "b"] แทน string[]

unknown มีต้นทุนตอน runtime ไหม?

ไม่มี เหมือน type อื่น ๆ ของ TypeScript มันหายไปตอน compile โค้ดที่เหลือตอน runtime มีแค่การเช็กที่คุณเขียนเพื่อ narrow เท่านั้น

Checklist เรื่อง type พื้นฐาน

  • เปิด "strict": true ในทุกโปรเจกต์
  • ปล่อยให้ตัวแปร local ใช้ inference ส่วน parameter และ return type ของ public API ให้ระบุเอง
  • ใช้ literal union กับชุดค่าที่ตายตัวและมีไม่มาก
  • ใช้ unknown กับ input ที่ไม่น่าไว้ใจ แล้ว narrow ด้วย type guard หรือ schema
  • ใช้ never ตรวจว่า switch ครอบคลุมทุกกรณี
  • เลือก satisfies และการเช็ก null ก่อนจะใช้ as
  • ใช้ as const เพื่อสร้าง type จากค่าตอน runtime

เมื่อพื้นฐานเหล่านี้แน่นแล้ว ขั้นต่อไปคือ TypeScript generics สำหรับเขียนฟังก์ชันที่ใช้ซ้ำได้และยัง type-safe ถ้าทีมของคุณกำลังเริ่ม codebase TypeScript และอยากวางโครงให้ดีตั้งแต่วันแรก Vectorkub ช่วยได้

พร้อมสร้างสิ่งที่ยิ่งใหญ่หรือยัง?

เล่าโปรเจคของคุณให้เราฟัง — เราจะพาคุณไปถึง

เลือกคุกกี้ที่เราใช้ได้ คุกกี้ที่จำเป็นช่วยให้เว็บไซต์ทำงานได้และเปิดใช้งานเสมอ