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

Verification tools

The AI Bug Catalog จบด้วยความจริงที่ไม่ค่อยสบายใจ: หนึ่งในสามของ 24 pattern ไม่มีเครื่องมือไหนในคอร์สนี้จับได้เลย บทนี้รันทุกเครื่องมือที่ จับได้จริง — บนโค้ดจริง พร้อม output จริงที่ quote ไว้ด้านล่าง — เพื่อให้รู้ชัดว่าแต่ละขั้นของบันไดให้อะไรจริง ๆ แล้วบันไดหมดตรงไหนที่คนต้องอ่านเอง

นี่คือพื้นฐานที่ทุกอย่างต่อยอดขึ้นมา รันตรง ๆ กับ sample ที่ self-contained ด้านล่าง (ไม่มี lint config แค่ compiler):

interface Settings { retries?: number }
async function saveAuditLog(message: string): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, 10));
console.log("saved:", message);
}
export function handleRequest(id: number): string {
saveAuditLog(`request ${id} handled`); // missing await -- floating promise
return `response-${id}`;
}
export function getRetries(settings: Settings): number {
return settings.retries || 3; // should be ??
}
function loadConfigFromUntypedLib(): any {
return { port: "3000" };
}
export function readPort(): number {
const config = loadConfigFromUntypedLib();
return config.port; // unsafe member access on any
}
export class Counter {
count = 0;
increment(): void {
this.count++;
}
}
const counter = new Counter();
export const handler = counter.increment; // unbound method
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
export function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
// missing "square" case -- switch-exhaustiveness-check should flag this
}
return 0;
}

Real output: ไม่มีอะไรเลย — tsc --strict exit 0 บนไฟล์นี้ bug สไตล์แคตาล็อกหกตัว (floating promise, || แทน ??, any รั่วจาก call ที่ไม่มี type, method reference ที่ไม่ bind, switch ที่ไม่ exhaustive) ทั้งหมดอยู่ในโค้ดที่ compiler มองว่าโอเคเป๊ะ --strict จับความขัดแย้งระหว่าง declared type; bug ข้างบนทุกตัวเป็นความผิดพลาดเชิง พฤติกรรม ที่ไม่มี declared type ไหนขัดแย้งด้วยเลย

tsgo (@typescript/native-preview) คือ checker ของ TypeScript ที่ port เป็น native Go แล้ว รันแบบเดียวกัน:

node_modules/.bin/tsgo --noEmit --strict ... (flag เหมือน tsc ข้างบนทุกประการ)

บน sample ข้างบน tsgo เห็นตรงกับ tsc เป๊ะ — 0 error เหมือนขั้นที่ 1 ความตรงกันนี้คาดไว้อยู่แล้ว แต่ควรพูดตรง ๆ เพราะสองตัวนี้ไม่ต่างกันมากเท่าประวัติของมันบอกไว้อีกต่อไป: ใน version ที่คอร์สนี้ติดตั้ง tsc เองก็เป็น native compiler ที่ port เป็น Go แล้ว ([email protected] ส่ง native binary มาเป็น tsc ของมันเลย) และ tsgo ก็คือ preview CLI ของการ rewrite ตัวเดียวกันนั้น วัดเวลาบนไฟล์สังเคราะห์ 800 บรรทัด (400 generic function) ทั้งคู่เสร็จในเวลาเท่ากัน ~30–35ms ซึ่งถูกครอบงำด้วย Node process startup ทั้งหมด ไม่ใช่เวลาตรวจ — ไม่มีช่องว่างความเร็วที่มีความหมายให้สังเกตในสเกลนี้ เพราะแทบไม่มีอะไรให้ตรวจเลย

ความต่างแบบ 10 เท่า ที่คอร์สนี้อ้างไว้ใน Why TypeScript — tsc ของ VS Code เองลดจาก 17.5 วินาทีเหลือ 1.3 วินาที ของ Canva จาก 58 วินาทีเหลือ 4.8 วินาที — เทียบ tsc แบบ classic ที่เขียนด้วย JavaScript (TypeScript 5.x/6.x) กับ native preview บน codebase จริงขนาดใหญ่หลายไฟล์ ยืนยันแบบเดียวกันที่นี่: [email protected] แบบ classic tsc จบไฟล์ 800 บรรทัดเดียวกันใน 38ms ซึ่งแทบเสมอกับ tsgo ที่ 32ms — เพราะแม้แต่ไฟล์เดียวที่ “ใหญ่” ตามมาตรฐานบทนี้ ก็ยังเล็กเกินกว่าจะแยกสอง engine ออกจากกันได้ ช่องว่าง 10 เท่านั้นจริง แต่มันโผล่มาตอนทั้งโปรเจกต์ที่ scale ของ incremental build ไม่ใช่บนไฟล์ .ts เดียวเล็ก ๆ — และมันปิดไปแล้วสำหรับโปรเจกต์ไหนก็ตาม (เหมือนโปรเจกต์นี้) ที่ tsc เองเป็น TypeScript 7

ตารางเท่าที่มีตอนนี้: tsgo จับเหมือนกับ tsc เป๊ะ บน flag เดียวกัน — มันเปลี่ยนความเร็ว compile บนโปรเจกต์ใหญ่ ไม่ได้เปลี่ยนว่า pattern ไหนใน 24 ตัวถูกจับ

compiler อย่างเดียวพลาด bug ไปหกตัวใน sample ข้างบน นี่คือช่องว่างพอดีที่ typed rule ของ typescript-eslint ปิดได้ — แต่มันต้องการข้อมูล type จริงจาก program ที่มี tsconfig รองรับ ซึ่งเป็นเหตุผลที่มันมีอยู่เป็นแค่ typed rule set ต่างหาก แยกจาก rule ที่ไม่ typed ใช้แค่ syntax ที่ starter config ส่วนใหญ่ติดตั้งมาให้ repo ของคอร์สนี้ไม่ได้ติดตั้ง typescript-eslint เลย (เพราะเล็งไปที่ TypeScript 7.0.2 ซึ่ง [email protected] ยังไม่รองรับ — ดู note ด้านล่าง) การรันด้านล่างใช้การติดตั้งแยกใน scratch ที่ pin ไว้กับ [email protected] ตรงตามที่ตัวเครื่องมือเองพิมพ์แนะนำเมื่อเจอ TS 7 (“run typescript-eslint using the TS 6 API”):

$ node_modules/.bin/eslint lintdemo/self_contained.ts --no-config-lookup
typescript-eslint does not support TS 7.0.
Please see https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/#running-side-by-side-with-typescript-6.0

Flat config ที่ใช้ (eslint.config.mjs หกตัวจาก rule ที่คอร์สนี้ตั้งชื่อไว้ บวก no-unsafe-* family ที่เกี่ยวข้องใกล้ ๆ):

import tseslint from 'typescript-eslint';
export default tseslint.config({
files: ['lintdemo/**/*.ts'],
languageOptions: {
parserOptions: { project: './lintdemo/tsconfig.json', tsconfigRootDir: import.meta.dirname },
},
extends: [...tseslint.configs.recommendedTypeChecked],
rules: {
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/switch-exhaustiveness-check': 'error',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/unbound-method': 'error',
'@typescript-eslint/prefer-nullish-coalescing': 'error',
},
});

Real output รันกับ sample เดียวกับขั้นที่ 1 ตรง ๆ:

9:3 error Promises must be awaited, end with a call to .catch, end with a call to
.then with a rejection handler or be explicitly marked as ignored with
the `void` operator @typescript-eslint/no-floating-promises
14:27 error Prefer using nullish coalescing operator (`??`) instead of a logical
or (`||`), as it is a safer operator @typescript-eslint/prefer-nullish-coalescing
17:38 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
22:9 error Unsafe assignment of an `any` value @typescript-eslint/no-unsafe-assignment
23:3 error Unsafe return of a value of type `any` @typescript-eslint/no-unsafe-return
23:17 error Unsafe member access .port on an `any` value @typescript-eslint/no-unsafe-member-access
34:24 error A method that is not declared with `this: void`
may cause unintentional scoping of `this` when
separated from its object. @typescript-eslint/unbound-method
41:11 error Switch is not exhaustive. Cases not matched:
"square" @typescript-eslint/switch-exhaustiveness-check
✖ 8 problems (8 errors, 0 warnings)

bug ทั้งหกที่ tsc --strict พลาดโผล่ที่นี่ครบ บวก diagnostic เพิ่มจาก no-unsafe-* ที่ได้มาฟรีจากการแพร่ของ any อีกสอง rule ที่คอร์สนี้ตั้งชื่อไว้ — no-misused-promises (Promise ที่คืนจาก callback ของ Array.prototype.forEach ซึ่งไม่สนใจมัน) กับ consistent-type-imports (value-style import ของชื่อที่ใช้เป็น type อย่างเดียว) — ก็ยิงแบบเดียวกันบนโค้ดที่มี shape นั้นจริง ๆ มันไม่มี output ข้างบนแค่เพราะ sample ที่ตัดมาไม่มี shape นั้นเท่านั้นเอง

ทุกเครื่องมือที่ผ่านมาตรวจ ค่า expectTypeOf ตรวจ type เอง — มีประโยชน์พอดีกับกฎ “claim ระดับ type ต้องพิสูจน์” ที่คอร์สนี้ยึดมาตั้งแต่ Phase 1: utility type ตัวหนึ่ง resolve เป็นสิ่งที่ผู้เขียนเชื่อจริงหรือเปล่า?

// @skip-verify vitest typecheck test, not a plain tsc fence -- real output quoted below
import { test, expectTypeOf } from "vitest";
type MyNonNullable<T> = T & {};
test("MyNonNullable strips null and undefined (passes)", () => {
expectTypeOf<MyNonNullable<string | null | undefined>>().toEqualTypeOf<string>();
});
test("MyNonNullable does NOT turn into number (fails on purpose)", () => {
expectTypeOf<MyNonNullable<string | null>>().toEqualTypeOf<number>();
});

Real output (vitest --run --typecheck):

❯ TS typetest/nonNullable.test-d.ts (2 tests | 1 failed)
× MyNonNullable does NOT turn into number (fails on purpose)
FAIL typetest/nonNullable.test-d.ts:9 > MyNonNullable does NOT turn into number (fails on purpose)
TypeCheckError: Type 'number' does not satisfy the constraint '"Expected number, Actual string"'.
10| expectTypeOf<MyNonNullable<string | null>>().toEqualTypeOf<number>();
Test Files 1 failed (1)
Tests 1 failed | 1 passed (2)

test แรกผ่านแบบเงียบ ๆ; test ที่สอง — ตั้งใจเขียนให้ผิด เพื่อพิสูจน์ว่า check นี้ตรวจจริง — พังด้วย message ที่บอกตรง ๆ ว่าคาดหวังอะไรกับได้อะไรจริง นี่คือเครื่องมือเดียวในบทนี้ที่พังได้เพราะ type ผิด แม้ทุกค่าในโปรแกรมจะทำงานถูกต้องสมบูรณ์ก็ตาม

attw ตรวจ output ที่ publish จริงของ package — package.json, exports, และไฟล์ .d.ts/.js ที่ tarball จาก npm pack จะส่งออกไป — ในมุมที่ resolver ของ consumer มองเห็น ไม่ใช่หน้าตาของ source tree

{
"name": "attw-demo",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
}
}

Real output (attw --pack บน tarball ที่ pack แล้ว):

attw-demo v1.0.0
⚠️ A require call resolved to an ESM JavaScript file, which is an error in Node and
some bundlers. CommonJS consumers will need to use a dynamic import.
┌───────────────────┬──────────────────────────────┐
│ │ "attw-demo" │
├───────────────────┼──────────────────────────────┤
│ node10 │ 🟢 │
├───────────────────┼──────────────────────────────┤
│ node16 (from CJS) │ ⚠️ ESM (dynamic import only) │
├───────────────────┼──────────────────────────────┤
│ node16 (from ESM) │ 🟢 (ESM) │
├───────────────────┼──────────────────────────────┤
│ bundler │ 🟢 │
└───────────────────┴──────────────────────────────┘

package นี้ไม่เคยประกาศ require condition เลย แล้ว main ก็ชี้ไปที่ไฟล์ ESM — attw จับจากภายนอกได้พอดีว่านี่คือ dual-package hazard แบบที่ compile ผ่านสมบูรณ์แต่พังเฉพาะตอน consumer ใช้ bundler หรือ require() เท่านั้น หลาย เดือนหลัง publish

Unused exports (1)
formatUserVerbose function format.ts:8:17

knip ตาม import ทุกตัวเริ่มจาก entry point ที่ประกาศไว้ แล้วเจอ exported function หนึ่งตัวที่ไม่มีใคร import เลย — สัญญาณเดียวกัน แต่ทั่วไปกว่า ที่แคตาล็อกของโมดูลนี้ใช้กับ pattern 17 (interface ที่มี implementer เดียว): export ที่ถูกใช้จากที่เดียว หรือไม่ถูกใช้เลย ควรไปดูอีกที แม้ knip จะไม่บอกว่า ทำไม abstraction นั้นไม่จำเป็น บอกแค่ว่ามันไม่มีใครอ้างถึง

Files: 89
Lines: 59924
Identifiers: 55831
Symbols: 35922
Types: 3941
Parse time: 0.013s
Bind time: 0.005s
Check time: 0.002s
Emit time: 0.000s
Total time: 0.020s

ตัวนี้ไม่จับ bug — มันบอกว่า เวลาของ compiler เองไปอยู่ที่ไหน ซึ่งเป็นสิ่งที่อธิบายจริง ๆ ว่าทำไม tsc ถึงช้า: parse vs. bind vs. check vs. emit ตัวเลข Types/Instantiations ที่พองขึ้นเรื่อย ๆ บนโปรเจกต์จริง (ไม่ใช่ sample เล็ก ๆ นี้) คือจุดแรกที่ควรดูเมื่อโค้ดระดับ type (recursive conditional, union ใหญ่ ๆ) เริ่มทำให้ CI ช้า

#Patternขั้นที่จับได้
1as บน JSON.parseไม่มี
2any รั่วจาก librarytypescript-eslint no-unsafe-member-access
3! บนค่าจาก APItypescript-eslint no-non-null-assertion (จับที่มี ! อยู่ ไม่ได้จับว่า bug จริง)
4@ts-ignore แทนการแก้จริงtypescript-eslint ban-ts-comment; tsc ทันทีที่ลบ comment ออก
5Record<string, any> แทน schemaไม่มี (no-explicit-any จับ any ไม่ได้จับ typo ที่มันปล่อยผ่าน)
6catch (e) { e.message }tsc โดยตรง (TS18046)
7optional + if (x) มอง 0 ว่าหายไปไม่มี
8|| แทน ??typescript-eslint prefer-nullish-coalescing
9readonly ตื้นไม่มีสำหรับ Readonly<T> ธรรมดา; tsc จับได้เฉพาะเมื่อเขียน DeepReadonly<T> เอง
10object ที่ mutable แชร์เป็น default exportไม่มี
11ลืม await (floating promise)typescript-eslint no-floating-promises
12Promise.all บน array ที่ widentsc โดยตรง (TS2339 แบบ reactive)
13switch exhaustive ไม่มี never checktypescript-eslint switch-exhaustiveness-check (จับโดยตรง ไม่ต้องเขียน never check เอง); tsc อย่างเดียวจับได้ก็ต่อเมื่อมี never check ที่เขียนเองอยู่แล้ว
14this หลุดใน callbacktypescript-eslint unbound-method
15Array<Dog> เข้า Array<Animal>ไม่มี
16type predicate ที่โกหกไม่มี
17interface + generic ใช้ครั้งเดียวknip (ทางอ้อม — export ที่ไม่ถูกอ้าง/ถูกอ้างที่เดียว)
18Omit บน unionไม่มี
19recursive type ลึกเกินtsc โดยตรง (TS2589)
20enum ใต้ erasable syntaxไม่มีผ่าน tsc/eslint — รันไฟล์จริงเท่านั้น
21parameter property ใต้ strip-typesไม่มีผ่าน tsc/eslint — รันไฟล์จริงเท่านั้น
22const enum reverse mappingtsc โดยตรง (TS2476)
23import แทน import typetsc (TS1484 ต้องเปิด flag); typescript-eslint consistent-type-imports (จับล่วงหน้า)
24Object.keys(obj).forEachtsc โดยตรง (TS7053)

สำหรับโปรเจกต์จริงที่ติดตั้งเครื่องมือเหล่านี้เป็น dev dependency ครบ:

npx tsc --noEmit --strict && npx eslint . --max-warnings 0 && npx vitest run --typecheck && npx knip

รัน npx attw --pack แยกต่างหาก เฉพาะ package ที่ publish จริงเท่านั้น — มันตรวจ tarball ที่ build แล้ว ไม่ใช่ source

เจ็ดขั้น แต่ละขั้นจับสิ่งที่ขั้นก่อนหน้าพลาด:
1. tsc --strict -> ความขัดแย้งระหว่าง declared type
2. tsgo -> ความขัดแย้งเดียวกัน ที่ความเร็วระดับโปรเจกต์ใหญ่ (พอ tsc เองเป็น native แล้ว ช่องว่างปิด)
3. typed eslint rule -> การใช้ค่าที่ type ถูกต้องแล้วผิดเชิงพฤติกรรม (floating promise, any, ||, this ไม่ bind, switch ไม่ exhaustive)
4. expectTypeOf -> TYPE resolve ตรงกับที่ผู้เขียนเชื่อหรือเปล่า ไม่เกี่ยวกับค่า runtime เลย
5. attw -> exports ของ package ที่ publish resolve ตามที่ package.json อ้างจริงหรือเปล่า
6. knip -> export/dependency ที่ไม่มีอะไรในโปรเจกต์อ้างถึงแล้ว
7. --extendedDiagnostics -> เวลาของ compiler เองไปอยู่ที่ไหน บน build ที่ช้าจริง
หนึ่งในสามของแคตาล็อกรอดทุกขั้น -- ช่องว่างนั้นคือสิ่งที่โมดูลที่เหลือฝึกให้อ่านด้วยตา
ได้อะไรประโยชน์ต้นทุน
รัน tsc --strict อย่างเดียวเร็ว ไม่ต้องมี dependency เพิ่ม จับทุกความขัดแย้งระหว่าง declared typeพลาดทุก bug เชิงพฤติกรรมที่บทนี้แสดง — หกตัวในไฟล์เล็ก ๆ ไฟล์เดียว
เพิ่ม typed rule ของ typescript-eslintปิดช่องว่างเชิงพฤติกรรมที่ tsc เหลือไว้ส่วนใหญ่ต้องมี program ที่มี tsconfig เต็มรูปแบบถึงจะรันได้ (ช้ากว่า lint แบบ syntax อย่างเดียว); ณ version TypeScript ของคอร์สนี้ ต้องติดตั้ง TS 6 แยกต่างหาก
เพิ่ม expectTypeOf type testขั้นเดียวที่พังได้เพราะ type ผิด แม้ runtime จะทำงานสมบูรณ์มีไฟล์ test เพิ่มให้เขียนและดูแล นอกเหนือจาก runtime test ปกติ
เพิ่ม attw/knipจับปัญหา packaging กับ export ที่ตายแล้ว ที่มองไม่เห็นจากใน source treeมีประโยชน์แค่กับ package ที่ publish (attw) หรือโปรเจกต์ที่มี entry point ชัดเจน (knip) — นอกนั้น noisy หรือไม่มีความหมาย
ต่อทุกตัวเข้า CIใกล้เคียงที่สุดที่คอร์สนี้ทำได้กับ “ตรวจทุกอย่างที่เครื่องมือตรวจได้”ยังเหลือหนึ่งในสามของแคตาล็อกที่ไม่ถูกจับ — เครื่องมือแค่ทำให้แคบลง ไม่เคยแทนการอ่าน
  • “tsc --strict ผ่าน แปลว่าโค้ดปลอดภัย” bug หกตัวใน sample file ของบทนี้เองพิสูจน์ตรงข้าม — --strict ตัดแค่ความขัดแย้งระหว่าง declared type ไม่เคยตัดความผิดพลาดเชิงพฤติกรรมที่ค่าซึ่ง type ถูกต้องก็ยังก่อได้
  • “tsgo เร็วกว่า tsc 10 เท่าเสมอ” จริงสำหรับ tsc แบบ classic ที่เขียนด้วย JS บนโปรเจกต์ใหญ่ — ไม่จริงที่นี่ ที่ tsc ของคอร์สนี้เป็น native compiler อยู่แล้ว และสังเกตไม่ได้เลยบนไฟล์เล็ก ๆ ไฟล์เดียว
  • “typed rule ของ typescript-eslint เป็นแค่เวอร์ชันเข้มกว่าของ default ที่ไม่ typed” มันเป็นคนละ category กันเลย — rule ที่ไม่ typed เห็นแค่ syntax; typed rule เห็น type จริงที่ resolve อยู่หลัง syntax นั้น ซึ่งเป็นเหตุผลที่ no-floating-promises ต้องการ tsconfig ไม่ใช่แค่ parser
  • “ถ้าทุกเครื่องมือในบันไดนี้ผ่านหมด โค้ดถูกต้อง” หนึ่งในสามของแคตาล็อก AI bug ถูกออกแบบมาให้รอดทุกขั้นในนี้โดยเจตนา — เครื่องมือผ่านหมดแค่ทำให้สิ่งที่ต้องอ่านต่อแคบลง ไม่ได้รับรองความถูกต้อง
sample file ในขั้นที่ 1 ของบทนี้มี bug สไตล์แคตาล็อกหกตัว (floating promise, || แทน ??, any รั่ว, method ไม่ bind, switch ไม่ exhaustive) แต่ tsc --strict รายงานว่าไม่มี error เลย ทำไม?
บทนี้วัด tsc 7.0.2 กับ tsgo เสร็จไฟล์ 800 บรรทัดในเวลาที่แทบเท่ากัน (~30-35ms) ในขณะที่ตัวเลขจาก blog อย่างเป็นทางการที่คอร์สนี้อ้างที่อื่นบอกว่า native compiler เร็วขึ้นราว 10 เท่า ทั้งสองอย่างจริงพร้อมกันได้ยังไง?
ทำไม rule no-floating-promises ของ typescript-eslint ต้องการ config แบบ typed (program ที่มี tsconfig จริงรองรับ) ในขณะที่ rule อย่าง no-var ไม่ต้องการ?
expectTypeOf<MyNonNullable<string | null>>().toEqualTypeOf<number>() พังด้วย TypeCheckError จริงใน vitest run ของบทนี้ มันพิสูจน์ว่า program state แบบไหนผิด ที่ runtime test ปกติพิสูจน์ไม่ได้?
output ของ attw ในบทนี้แสดง warning จริงสำหรับ package ที่ exports map มีแค่ condition import (ไม่มี require) ในขณะที่ main ชี้ไปที่ไฟล์ ESM warning นี้ทำนายว่าอะไรจะพัง แล้วพังสำหรับใคร?