TypeScript declaration file คือไฟล์ .d.ts ที่อธิบายหน้าตาของโค้ด JavaScript โดยไม่มี implementation อยู่ข้างในเลย มันบอก compiler ว่า module หนึ่ง export function, class และตัวแปรอะไรออกมาบ้าง และแต่ละตัวมี type อะไร ทุกครั้งที่เรา import library ใน TypeScript ทั้ง autocomplete ใน editor และการตรวจ type ของ compiler ล้วนมาจาก declaration file ไม่ว่าจะมากับตัว library เอง ติดตั้งจาก package @types หรือเราเขียนขึ้นเอง
บทความนี้อธิบายว่าในไฟล์ .d.ts มีอะไร TypeScript หาไฟล์เหล่านี้จากที่ไหน วิธีติดตั้ง package @types รวมถึง package ที่มี scope และวิธีเขียน declaration เองด้วย declare module และ declare global เมื่อ library ไม่มี type มาให้
ในไฟล์ TypeScript declaration file มีอะไร
declaration file มีแต่ข้อมูล type ลองเทียบ module JavaScript เล็ก ๆ กับ declaration ของมัน:
// src/lib/format.js
export function formatPrice(amount, currency = "THB") {
return new Intl.NumberFormat("th-TH", { style: "currency", currency }).format(amount);
}
export const VERSION = "1.4.0";// src/lib/format.d.ts
export declare function formatPrice(amount: number, currency?: string): string;
export declare const VERSION: string;เมื่อโค้ด TypeScript import ./lib/format.js compiler จะอ่าน type จาก format.d.ts และไม่ยุ่งกับโค้ดที่รันจริง กติกามีไม่กี่ข้อ:
- ไม่มี body ของ function ไม่มีค่าเริ่มต้น ไม่มี logic มีแต่ signature
declareแปลว่า "สิ่งนี้มีอยู่จริงตอน runtime เชื่อได้" ในไฟล์.d.tsทุก declaration ระดับบนสุดเป็น ambient อยู่แล้ว ตัวที่ export จึงละdeclareได้ แต่เขียนไว้จะสื่อเจตนาชัดกว่า- interface และ type alias เขียนได้ตามปกติ เพราะมันไม่มีตัวตนตอน runtime อยู่แล้ว
compiler เชื่อ declaration file แบบร้อยเปอร์เซ็นต์ .d.ts ที่ผิดจึงแย่กว่าไม่มีเลย ถ้าไฟล์บอกว่า function คืน string แต่บางครั้งคืน undefined จริง TypeScript จะไม่เตือนอะไรเลย
TypeScript หา type จากที่ไหน
เมื่อเขียน import { debounce } from "lodash-es" TypeScript จะหา type ตามลำดับคร่าว ๆ ดังนี้:
package.jsonของ package เอง: conditiontypesในexportsหรือ fieldtypesระดับบนสุด (หรือtypingsแบบเก่า)- ไฟล์
.d.tsที่วางคู่กับ entry point ของ JavaScript เช่นindex.d.ts - package ที่ชื่อตรงกันใน
node_modules/@typesเช่น@types/lodash-es
ถ้าไม่เจอเลยและเปิด noImplicitAny อยู่ (ซึ่งเปิดอยู่แล้วเมื่อใช้ strict) จะเจอ error TS7016:
error TS7016: Could not find a declaration file for module 'legacy-slugify'.
'/app/node_modules/legacy-slugify/index.js' implicitly has an 'any' type.library รุ่นใหม่หลายตัว เช่น axios, zod และ date-fns แนบ type มาเอง ข้อ 1 จึงครอบคลุมแล้วไม่ต้องทำอะไรเพิ่ม ส่วน library ที่ไม่มี ชุมชนดูแล type ไว้ใน repository DefinitelyTyped และ publish ขึ้น npm ภายใต้ scope @types
ติดตั้ง package @types
package type ใช้แค่ตอน compile จึงติดตั้งเป็น dev dependency:
npm install -D @types/lodash
# or
yarn add -D @types/lodash
# or
pnpm add -D @types/lodashตัวที่โปรเจกต์ Node แทบทุกโปรเจกต์ต้องมีคือ @types/node สำหรับ built-in module ของ Node และ @types/express หรือ @types/jest ถ้าใช้ library เหล่านั้น
ก่อนติดตั้งให้เช็กก่อนว่า library มี type มาในตัวหรือยัง บนเว็บ npm จะมี badge "TS" ข้าง package ที่มี type ในตัว และ badge "DT" ถ้ามี type บน DefinitelyTyped การติดตั้ง @types ให้ library ที่มี type อยู่แล้วจะทำให้ declaration ซ้ำหรือขัดกัน
อย่าลืมให้เวอร์ชันตรงกัน package @types ใช้เลข major และ minor ตาม library ที่มันอธิบาย @types/[email protected] จึงหมายถึง [email protected] ถ้าเวอร์ชันไม่ตรง อาจได้ type ของ API ที่ไม่มีในเวอร์ชันที่ติดตั้งอยู่
ชื่อ package ที่มี scope: @types/scope__name
scope ของ npm ใช้ slash อยู่แล้ว (@babel/core) และชื่อ package มี slash ได้แค่ตัวเดียว DefinitelyTyped จึงตัด @ ออก แล้วเปลี่ยน slash เป็น underscore สองตัว:
| Library | Types package |
|---|---|
lodash | @types/lodash |
@babel/core | @types/babel__core |
@babel/traverse | @types/babel__traverse |
@myorg/pam | @types/myorg__pam |
npm install -D @types/babel__coreคุม global type ด้วย types และ typeRoots
โดยค่าเริ่มต้น TypeScript จะโหลดทุก package ใน node_modules/@types เป็น global declaration ซึ่งอาจชนกันได้ เช่นเมื่อมีทั้ง @types/jest และ @types/mocha ที่ต่างก็ประกาศ describe แบบ global option types ช่วยจำกัดตรงนี้:
{
"compilerOptions": {
"types": ["node", "vitest/globals"]
}
}option นี้มีผลแค่กับ package ที่โหลดแบบ global ถ้าเขียน import express from "express" ตรง ๆ ก็ยังหา @types/express เจอเหมือนเดิม ส่วน typeRoots ใช้เปลี่ยนที่ที่ TypeScript ไปหา global package เหล่านั้น ซึ่งแทบไม่ต้องแตะ
สร้าง declaration file จากโค้ดของเราเอง
ถ้าเรา publish library ที่เขียนด้วย TypeScript ให้ compiler สร้าง .d.ts ให้:
{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"outDir": "dist"
}
}จากนั้นชี้ให้ผู้ใช้ไปที่ไฟล์เหล่านั้นใน package.json:
{
"name": "@myorg/pam",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
}declarationMap ทำให้ "Go to Definition" กระโดดไปที่ source .ts แทนที่จะไปที่ .d.ts ถ้าใช้เครื่องมืออื่นอย่าง esbuild หรือ SWC build JavaScript ให้เพิ่ม emitDeclarationOnly เพื่อให้ tsc สร้างแค่ type ส่วน TypeScript 5.5 เพิ่ม isolatedDeclarations ซึ่งบังคับให้ export ทุกตัวระบุ return type ชัดเจน เครื่องมืออื่นจึงสร้าง declaration ได้เร็วทีละไฟล์
เขียน type ให้ library ที่ไม่มี type ด้วย declare module
เมื่อ library ไม่มี type และไม่มี package @types ให้เขียน declaration เอง สร้างโฟลเดอร์อย่าง types/ แล้วตรวจว่า tsconfig.json include มันแล้ว:
{
"include": ["src", "types"]
}ทางลัด: shorthand declaration
ถ้าแค่อยากให้ TS7016 หายไปก่อน ให้ประกาศ module แบบไม่มี body:
// types/legacy-slugify.d.ts
declare module "legacy-slugify";ทุกอย่างที่ import จาก module นี้จะเป็น any build ผ่านก็จริง แต่ไม่มีการตรวจ type เลย ควรใช้เป็นขั้นชั่วคราวเท่านั้น
เขียน declaration ให้ถูกต้อง
ทางที่ดีกว่าคืออธิบายเฉพาะส่วนที่เราใช้ สมมติ legacy-slugify เป็น package CommonJS ที่เขียนว่า module.exports = function slugify(input, options) {...}:
// types/legacy-slugify.d.ts
declare module "legacy-slugify" {
interface SlugifyOptions {
separator?: string;
lowercase?: boolean;
}
function slugify(input: string, options?: SlugifyOptions): string;
export = slugify;
}export = คือรูปแบบของ module.exports = ... เมื่อเปิด esModuleInterop แล้วก็เขียน import slugify from "legacy-slugify" ได้
ถ้าเป็น ES module ที่มี named export ให้ export ทีละตัว:
declare module "lodash" {
export function last<T>(array: readonly T[]): T | undefined;
}สังเกต T | undefined เพราะสมาชิกตัวสุดท้ายของ array ว่างคือ undefined declaration ก็ต้องบอกแบบนั้น ในงานจริงเราจะติดตั้ง @types/lodash แทนที่จะเขียนเอง แต่ตัวอย่างนี้แสดงรูปแบบให้เห็น
มีกฎข้อหนึ่งที่คนพลาดบ่อย: ไฟล์ .d.ts ที่มี import หรือ export ระดับบนสุดจะกลายเป็น module และใน module นั้น declare module "x" จะถูกตีความเป็นการ augment module ที่มีอยู่แล้ว ไม่ใช่การประกาศใหม่ ถ้าจะเขียน type ให้ package ที่ไม่มี type ให้ไฟล์นั้นไม่มี import ระดับบนสุด ถ้าต้องใช้ type จากที่อื่น ให้ใช้ import("...") แบบ inline แทน
import ไฟล์ที่ไม่ใช่โค้ด
bundler ให้เรา import ไฟล์อย่างรูปภาพหรือ CSS module ได้ TypeScript ต้องมี wildcard declaration สำหรับไฟล์เหล่านี้:
// types/assets.d.ts
declare module "*.svg" {
const src: string;
export default src;
}
declare module "*.module.css" {
const classes: Readonly<Record<string, string>>;
export default classes;
}เครื่องมืออย่าง Vite มี declaration พวกนี้ให้แล้วใน vite/client เช็กก่อนเขียนเอง
Module augmentation และ global augmentation
บางครั้ง type มีอยู่แล้ว แต่เราต้องเพิ่มอะไรเข้าไป TypeScript รวม interface ที่ชื่อเดียวกันเข้าด้วยกัน (declaration merging) ซึ่งเป็นข้อแตกต่างหลักข้อหนึ่งที่อธิบายไว้ใน TypeScript interface vs type alias
เพิ่ม property ให้ Request ของ Express
middleware สำหรับ authentication มักแนบข้อมูล user ไว้กับ request @types/express ประกาศ Request ไว้ใน namespace Express แบบ global เราจึงขยายมันได้:
// src/types/express.d.ts
declare global {
namespace Express {
interface Request {
user?: { id: string; role: "admin" | "member" };
}
}
}
export {};ตอนนี้ req.user มี type ในทุก handler แล้ว ส่วน export {} ทำให้ไฟล์เป็น module ซึ่ง declare global ต้องการ
กำหนด type ให้ global: window และ process.env
ใช้ pattern เดียวกันกับค่าที่ถูก inject เข้ามาตอน runtime หรือตอน build:
// src/types/globals.d.ts
declare global {
interface Window {
__APP_CONFIG__: { apiUrl: string; release: string };
}
namespace NodeJS {
interface ProcessEnv {
NODE_ENV: "development" | "production" | "test";
DATABASE_URL: string;
}
}
}
export {};ProcessEnv ต้องมี @types/node และต้องจำไว้ว่านี่เป็นแค่คำสัญญากับ compiler ถ้า DATABASE_URL ไม่มีจริงตอน runtime ก็ไม่มีอะไรมาหยุด ควร validate environment variable ตอนที่แอปเริ่มทำงาน
เช็กลิสต์เมื่อ type หาไม่เจอ
เมื่อ type ของ library resolve ไม่ได้ ให้ไล่ตามลำดับนี้:
- เช็กว่า package มี type มาในตัวไหม (
typesหรือexportsในpackage.jsonของมัน) - ถ้าไม่มี ติดตั้ง
@types/<name>หรือ@types/<scope>__<name>สำหรับ package ที่มี scope - ถ้าไม่มี package
@typesให้เพิ่มไฟล์declare moduleเฉพาะส่วนที่ใช้ - ตรวจว่าไฟล์
.d.tsอยู่ในincludeของtsconfig.jsonและ listtypesที่เข้มงวดไม่ได้บัง global package อยู่ - ตรวจว่าเวอร์ชันของ
@typesตรงกับเวอร์ชันของ library - ใช้
skipLibCheck: trueเพื่อข้าม error ใน declaration file ของ third-party ได้ แต่อย่าใช้มันซ่อนความผิดพลาดในไฟล์ของเราเอง
คำถามที่พบบ่อย
ไฟล์ .ts กับ .d.ts ต่างกันอย่างไร
ไฟล์ .ts มีทั้งโค้ดและ type และถูก compile เป็น JavaScript ส่วนไฟล์ .d.ts มีแต่ type ไม่สร้าง output ใด ๆ และใช้อธิบาย JavaScript ที่มีอยู่แล้วที่อื่น
package @types ควรอยู่ใน dependencies หรือ devDependencies
ถ้าเป็น application ให้ใส่ใน devDependencies เพราะ type ใช้แค่ตอน build ถ้าเป็น library ที่ publish และไฟล์ .d.ts ของเราอ้างถึง package @types ตัวไหน ให้ใส่ตัวนั้นใน dependencies เพื่อให้ผู้ใช้ได้ไปด้วย
แก้ error "Could not find a declaration file for module" อย่างไร
ติดตั้ง package @types ที่ตรงกันถ้ามี ถ้าไม่มี ให้สร้างไฟล์ .d.ts ที่มี declare module "name" จะเป็นแบบ shorthand ที่ทุกอย่างเป็น any หรือเขียน signature จริงของ function ที่ใช้ก็ได้
ติดตั้ง type ให้ package ที่มี scope อย่าง @babel/core อย่างไร
ตัด @ ออก แล้วเปลี่ยน slash เป็น underscore สองตัว: npm install -D @types/babel__core
ส่ง type ให้ library คนอื่นได้ไหม
ได้ DefinitelyTyped รับ pull request แต่ถ้า library ยังมีคนดูแลอยู่ การเสนอ type ให้ตัว library เองมักดีกว่า เพราะ type จะออกพร้อมทุก release และไม่หลุดเวอร์ชัน
สรุป
declaration file คือสัญญาระหว่าง TypeScript กับ JavaScript เลือกใช้ library ที่มี type มาในตัวก่อน ถ้าไม่มีให้ใช้ package @types ที่เวอร์ชันตรงกัน และเขียนไฟล์ declare module เฉพาะส่วนที่เหลือ ใช้ declare global และ interface merging เพื่อขยาย type ที่มีอยู่ แทนการ cast เป็น any ถ้ากำลังเริ่มโปรเจกต์ใหม่ คู่มือตั้งค่าโปรเจกต์ TypeScript อธิบายพื้นฐานของ tsconfig.json ไว้ และถ้าต้องการคนช่วยวางโครงสร้าง codebase TypeScript ขนาดใหญ่ Vectorkub รับพัฒนาและดูแล web application และช่วยทีมของคุณได้
