JSON Toolkit

JSON เป็น TypeScript: interface, type guard และขีดจำกัดของการสร้างโค้ด

สรุปสำคัญ: การสร้าง interface จากตัวอย่าง JSON เป็นเพียงครึ่งเดียวของงาน: ฟิลด์ที่ไม่บังคับ null union วันที่ และจำนวนเต็มขนาดใหญ่ต้องมีมนุษย์ตรวจอีกรอบ และ type ไม่ทำ validation ตอนรันไทม์

ระดับกลางอ่านประมาณ 9 นาทีเผยแพร่ 2026-10-31อัปเดต 2026-11-04
ภาพหน้าปกคู่มือ JSON เป็น TypeScript interface และ type guard

สรุปสั้น: สร้าง interface เพื่อประหยัดการพิมพ์ แล้วตรวจความไม่บังคับและ null ซ้ำ คงวันที่และจำนวนเต็มขนาดใหญ่เป็นสตริง และเพิ่ม validation ตอนรันไทม์ที่ขอบเขต; ถ้าถือ type ที่สร้างมาเป็นสัญญาก็จะไม่มีการตรวจสอบใด ๆ เลย

ตัวสร้างเห็นเพียงตัวอย่างเดียวที่คุณวางลงไป ฟิลด์จะหายได้หรือไม่ ค่าจะเป็น null ได้หรือไม่ และวันที่ใช้รูปแบบใด ล้วนเป็นการเดา — และการเดาที่ผิดก็เป็นหน้าที่คุณต้องแก้

1. จริง ๆ แล้วตัวสร้างทำอะไร

วางตัวอย่าง JSON ลงใน JSON เป็น TypeScript แล้ว interface ที่ได้กลับมาคือ บทสรุปรูปร่าง: มันนับคีย์และชนิดค่าที่เห็น แล้วเลือกการแทนค่าที่แคบที่สุดที่หาได้

{
  "id": 1,
  "email": "ada@example.com",
  "profile": { "nickname": "Ada", "age": 36 },
  "tags": ["admin", "beta"],
  "createdAt": "2026-10-31T08:00:00Z"
}

เอาต์พุตคร่าว ๆ มีหน้าตาแบบนี้:

interface User {
  id: number;
  email: string;
  profile: { nickname: string; age: number };
  tags: string[];
  createdAt: string;
}

โค้ดนี้คอมไพล์ผ่าน แต่มันอธิบาย ค่าที่ในอุดมคติ ไม่ใช่สัญญาจริงของ endpoint ตัวสร้างไม่รู้ว่า email หายได้หรือไม่ tags ว่างได้หรือไม่ หรือ createdAt พาเขตเวลามาเสมอหรือเปล่า มันไม่รู้ด้วยซ้ำว่า id อาจเป็นสตริงในเรกคอร์ดอื่น

ความคาดหวังที่ถูกต้องคือ: ตัวสร้างประหยัดการพิมพ์ มนุษย์เป็นผู้ให้ความหมาย

2. ฟิลด์ที่ไม่บังคับและ null: ? ไม่เท่ากับ | null

นี่คือส่วนของเอาต์พุตที่ถูกคัดลอกบ่อยที่สุด และเป็นส่วนที่พังบ่อยที่สุด

รูปแบบ TypeScript ความหมาย JSON ที่ตรงกัน
email: string มีเสมอ เป็นสตริงเสมอ {"email":"a@b.c"}
email?: string คีย์อาจหายไป; เมื่อมีอยู่ต้องไม่เป็น null {} หรือ {"email":"a@b.c"}
email: string | null คีย์มีเสมอ ค่าอาจเป็น null {"email":null}
email?: string | null คีย์อาจหายไปและค่าอาจเป็น null ทั้งสามกรณี

ตัวสร้างเห็นตัวอย่างเดียว จึงมักส่งรูปแบบแรกออกมา ถ้า API จริงคืน null ได้ type ก็โกหก: ทุกอย่างคอมไพล์ผ่าน แล้ว email.toLowerCase() ก็ throw ตอนรัน

มีสองวิธีแก้:

  1. เก็บตัวอย่างจริงหลายชุดที่ครอบทั้ง "คีย์หาย" และ "ค่าเป็น null" แล้วให้ตัวสร้างอนุมานใหม่
  2. หลังสร้างเสร็จ แก้ฟิลด์ด้วยมือเป็น ?: T | null และจัดการทั้งสองสาขาในโค้ดจริง

อย่าใช้ทางลัดทำทุกฟิลด์เป็น ?: string ความไม่บังคับหมายถึง "คีย์อาจไม่มี" ไม่ใช่ "ค่าอาจว่าง" undefined กับ null ยังทำงานต่างกันใน JSON.stringify: ตัวแรกถูกละไป ตัวหลังถูกเก็บเป็น null

3. องค์ประกอบ array ที่ไม่สม่ำเสมอและ union type

Array เป็นโครงสร้างที่ทำให้ผิดพลาดง่ายที่สุด ถ้า tags ปรากฏเป็น ["admin"], [] และ ["beta", 3] ในสามเรกคอร์ด ตัวสร้างอาจส่ง string[], (string | number)[] หรือ unknown[] ขึ้นกับว่ามันเห็นตัวอย่างใด

มนุษย์ต้องตัดสิน:

  • ชนิดขององค์ประกอบต่างกันจริง: เขียน (string | number)[] แล้ว narrow ตอนใช้งาน
  • มีเพียงเรกคอร์ดเดียวที่สกปรก: แก้ข้อมูล อย่าซ่อนด้วย any[]
  • Array ว่าง: ตัวสร้างอนุมานชนิดองค์ประกอบไม่ได้และถอยไปใช้ never[] หรือ any[] คุณจึงต้องเติมเอง
  • Tuple ความยาวคงที่: ใช้ [string, string] เฉพาะเมื่อธุรกิจรับประกันความยาวจริง ๆ ไม่อย่างนั้นการเพิ่มองค์ประกอบภายหลังจะกลายเป็น compile error

Array ของ object ก็ต้องระวังเมื่อคีย์ไม่สม่ำเสมอ:

interface OrderItem {
  sku: string;
  quantity: number;
  discount?: number; // some records do not carry this field
}

ใส่ความไม่บังคับที่ชนิดขององค์ประกอบ ไม่ใช่ที่ array items?: OrderItem[] หมายถึง array เองอาจหายไป ซึ่งเป็นข้อความที่ต่างกันสิ้นเชิง

4. วันที่ จำนวนเต็มขนาดใหญ่ enum: ชนิดที่ JSON ไม่มี

JSON มีเพียงสตริงและตัวเลข ความหมายเชิงธุรกิจอยู่ในข้อตกลง ตัวสร้างอนุมานจากค่าคงที่ คุณจึงต้องแก้ด้วยมือ:

ค่าคงที่ JSON เอาต์พุตที่มักได้ สิ่งที่ควรเป็น
"2026-10-31T08:00:00Z" string คง string ไว้ในชั้นส่งข้อมูลและแปลงตอนใช้งาน; ห้ามประกาศ Date โดยตรง
1717075200000 number ใช้ number สำหรับ timestamp หน่วยมิลลิวินาทีและระบุหน่วยในเอกสาร
"1234567890123456789" string คงเป็น string เพื่อไม่ให้ number สูญเสียความแม่นยำ
1 กับ "1" ผสมกัน number | string ทำให้เป็นรูปแบบเดียว; อย่าผลัก union เข้าโค้ดธุรกิจ
"admin" string union ของค่าคงที่ 'admin' | 'editor' หรือค่าคงที่ enum ที่ใช้ร่วม
true กับ "true" ผสมกัน boolean | string แก้ต้นแหล่งข้อมูลแทนการสร้าง type นี้

กฎที่สำคัญที่สุด: วันที่ใน JSON เป็นสตริงเสมอ การเขียน createdAt: Date เป็นการหลอกตัวเองที่พบบ่อย — JSON.parse ไม่เคยสร้าง Date ค่าตอนรันจึงยังเป็นสตริงและ .getTime() จะ throw ให้คงชนิดการส่งข้อมูลเป็น string เรียก new Date(value) อย่างชัดเจนที่ขอบเขต และตรวจด้วย Number.isNaN

จำนวนเต็มขนาดใหญ่ก็เช่นกัน เมื่อ id เกิน 2^53 JSON.parse สูญเสียความแม่นยำไปแล้ว และการสร้างเป็น number ก็เพียงตอกย้ำบั๊กลงใน type แบ็กเอนด์คืนสตริง ฟรอนต์เอนด์คงเป็นสตริง; นั่นคือตัวเลือกเดียวที่เชื่อถือได้

5. Type ที่สร้างมาไม่ทำ validation ตอนรันไทม์

Type ของ TypeScript ถูกลบตอนคอมไพล์ ไม่ว่า interface จะแม่นยำแค่ไหนก็หยุด response ที่เป็น {"id": "x"} ไม่ได้ — มันจำกัดโค้ดของคุณ ไม่ได้จำกัดไบต์ที่มาถึงผ่านเครือข่าย

/**
 * Check whether a runtime value is a user object.
 * @param value The runtime value to validate
 * @returns Whether it satisfies the minimum shape of User
 */
function isUser(value: unknown): value is User {
  if (typeof value !== 'object' || value === null) {
    return false;
  }
  const candidate = value as Record<string, unknown>;
  return typeof candidate.id === 'number' && typeof candidate.email === 'string';
}

Type guard ครอบคลุมเฉพาะฟิลด์ที่คุณเขียนลงในการตรวจ เมื่อฟิลด์เพิ่มขึ้น guard ที่เขียนมือจะกลายเป็นนิยามที่สองที่ต้องดูแล และค่อย ๆ ห่างจาก interface

ข้อแลกเปลี่ยนในทางปฏิบัติมีหน้าตาแบบนี้:

แนวทาง Validation ตอนรัน แหล่งของ type เหมาะกับ
Interface อย่างเดียว ไม่มี เขียนมือหรือสร้างมา ข้อมูลภายในที่เชื่อถือได้ ข้อจำกัดตอนคอมไพล์
Interface บวก guard ที่เขียนมือ มี สองนิยามที่ค่อย ๆ ห่างกัน ฟิลด์น้อย ขอบเขตชัดเจน
zod / valibot มี type อนุมานจาก schema API ภายนอก ฟอร์ม คอนฟิก
JSON Schema บวก validator มี type สร้างจาก schema สัญญาข้ามภาษา เอกสาร

ถ้าข้อมูลมาจากภายนอก ให้ใช้ schema เป็นแหล่งความจริงเดียวและได้ type มาจาก z.infer หรือ json-schema-to-ts จากนั้น "validation ผ่าน" กับ "type ถูกต้อง" เป็นข้อเท็จจริงเดียวกัน และไม่มีนิยามที่สองให้ห่างกัน สำหรับแนวทางซิงค์สองทิศทางแบบเต็ม ดู การซิงค์ Schema กับ type ของ TypeScript

6. Interface หรือ type: อย่าเถียงเรื่องเอาต์พุตที่สร้างมา

ตัวสร้างมักส่ง interface ออกมา แต่สองอย่างนี้ใช้แทนกันได้ในเกือบทุกสถานการณ์ สิ่งที่สำคัญจริง ๆ คือสามความสามารถ:

  • Declaration merging: interface ประกาศซ้ำได้และรวมกันอัตโนมัติ; type ทำไม่ได้ ช่วยตอนแพตช์ type ของบุคคลที่สาม แต่โมเดลธุรกิจควรเลี่ยง — การรวมกันทำให้ "ฟิลด์เกิน" ลอดผ่านคอมไพเลอร์ได้โดยไม่มีใครสังเกต
  • Union และ mapped type: type แสดง A | B, Partial<T> และ template literal type ได้โดยตรง; interface ทำไม่ได้
  • การเรียกซ้ำ: ทั้งคู่รองรับ แต่รูปร่างที่เรียกซ้ำซับซ้อนอ่านได้เป็นธรรมชาติกว่าเมื่อเป็น type

กฎของทีมที่ใช้ได้จริงกว่า: ใช้ interface กับรูปร่างของ object, type กับ union, utility type และ function type ปล่อยเอาต์พุตที่สร้างมาไว้ตามค่าเริ่มต้น แล้วใช้เวลารีวิวกับความไม่บังคับและ validation ตอนรันไทม์แทน

7. Toolchain: สามวิธีในการสร้าง

แนวทาง อินพุต จุดแข็ง ข้อจำกัด
JSON เป็น TypeScript ของเว็บไซต์นี้ ตัวอย่าง JSON ใช้ได้ทันที ประมวลผลในเครื่อง อ้างอิงเฉพาะตัวอย่างปัจจุบัน; ต้อง narrow ด้วยมือ
quicktype JSON / JSON Schema เอาต์พุตหลายภาษา ต่อเข้ากับ CLI ได้ การอนุมานแบบอนุรักษ์นิยม union อาจกว้างเกิน
json-schema-to-typescript JSON Schema ขับเคลื่อนด้วยสัญญาและทำซ้ำได้ ต้องมี schema ที่เชื่อถือได้ก่อน

คำสั่ง quicktype ทั่วไป:

npx quicktype -s json -o user.ts user.json --just-types

--just-types ส่งเฉพาะ type ไม่มีโค้ดแปลงตอนรัน เหมาะกับโปรเจกต์ที่มีชั้นแยกวิเคราะห์อยู่แล้ว ถ้า schema เขียนด้วยมือหรือผลิตโดยแบ็กเอนด์ json-schema-to-typescript เข้ากว่าเพราะเคารพ required, enum และ additionalProperties — ข้อจำกัดที่การอนุมานจากตัวอย่างมองไม่เห็น

การสร้าง schema มีกับดักของตัวเอง เช่นทำทุกฟิลด์เป็น required หรือเปลี่ยน null เป็นไม่บังคับอย่างเงียบ ๆ; ดู กับดักที่พบบ่อยของตัวสร้าง JSON Schema

8. รายการตรวจสอบก่อนคอมมิต type ที่สร้างมา

ไล่ห้าข้อนี้ก่อนคอมมิตเอาต์พุต:

  1. ทำให้ชื่อฟิลด์เป็นมาตรฐาน: อย่าให้ userId กับ user_id อยู่ด้วยกัน; แมปครั้งเดียวในชั้นแปลง
  2. ตรวจความไม่บังคับซ้ำ: เทียบกับ response จริงและหาว่าฟิลด์ใดหายไปหรือเป็น null ได้
  3. คงวันที่และ id เป็นสตริง: ระบุหน่วยและรูปแบบใน type และแยกวิเคราะห์อย่างชัดเจนตอนใช้งาน
  4. ลด any และ unknown: ทุกที่ที่ตัวสร้างส่ง any ให้เพิ่ม type หรือคง unknown ไว้และเพิ่ม guard
  5. เพิ่ม validation ตอนรันไทม์: ตรวจข้อมูลภายนอกอย่างน้อยหนึ่งครั้ง; อย่าคาดหวังให้ interface จับข้อมูลเสีย

ถ้าโปรเจกต์ใช้ไลบรารี JSON หลายตัว (JSON.parse, superjson, lossless-json) พฤติกรรมกับจำนวนเต็มขนาดใหญ่และวันที่จะต่างกัน จึงต้องยืนยันตอนเลือก ดู การเลือกไลบรารีแยกวิเคราะห์ JSON

ทำไมตัวสร้างทำทุกฟิลด์ที่ไม่บังคับให้เป็น required

มันเห็นตัวอย่างเดียว ทุกคีย์ที่ปรากฏจึงถูกถือว่ามีเสมอ ให้ตัวอย่างหลายชุดที่ครอบกรณีต่าง ๆ หรือสร้างจาก JSON Schema — array required ในนั้นคือแหล่งอ้างอิงที่เชื่อถือได้

ทำไมการประกาศ Date ใน interface ถึงล้มเหลวตอนรัน

JSON.parse ผลิตได้เพียงสตริง ตัวเลข บูลีน null object และ array; มันไม่เคยสร้าง Date การประกาศ Date เป็นคำสัญญาตอนคอมไพล์ ขณะที่ค่าตอนรันยังเป็นสตริง ให้คง string ในชั้นส่งข้อมูลและแปลงที่ขอบเขต

Type ที่สร้างมาใช้เป็น validation ได้ไหม

ไม่ได้ Type ของ TypeScript ถูกลบหลังคอมไพล์และไม่ได้บังคับอะไรกับข้อมูลตอนรัน ให้ตรวจข้อมูลภายนอกด้วย zod, valibot, ajv หรือ type guard ที่เขียนมือ

id ที่เป็นจำนวนเต็มขนาดใหญ่ควรพิมพ์เป็น number หรือ string

ถ้าเกิน 2^53 ได้ ต้องเป็น string JSON.parse สูญเสียความแม่นยำระหว่างแยกวิเคราะห์ และการพิมพ์เป็น number ก็เพียงเขียนบั๊กลงใน type ที่แก้ไม่ได้อีกต่อไป