TypeScript 라이브러리에 타입 선언 작성하기
1. 개요
TypeScript 라이브러리 사용자는 두 가지가 필요합니다: 실행할 JavaScript 코드와 에디터가 자동 완성을 제공하고 컴파일러가 오류를 감지하는 타입 선언 파일(.d.ts)이 필요합니다. .d.ts가 없거나 package.json이 잘못된 파일을 가리키면, 사용자는 곳곳에서 any를 받거나 Could not find a declaration file for module 오류를 만날 것입니다.
이 글에서는 주문 가격을 계산하는 작은 라이브러리 order-pricing을 구축합니다. 읽고 나면, .d.ts를 생성하기 위해 tsconfig.json을 구성하는 방법, package.json에서 exports를 선언하는 방법, 기존 JavaScript 코드에 대해 수동으로 declaration을 작성하는 방법, 그리고 publish 전에 런타임과 타입을 모두 테스트하는 방법을 알게 됩니다.
2. 프로젝트 초기화
디렉토리를 만들고 필요한 도구를 설치합니다:
mkdir order-pricing && cd order-pricing
npm init -y
npm install --save-dev typescript vitest
typescript은 devDependencies에만 필요합니다. 라이브러리 사용자는 컴파일러가 필요 없으며, 미리 빌드된 .js와 .d.ts 파일만 사용합니다.
2.1. tsconfig.json 구성
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"rootDir": "src",
"outDir": "dist",
"isolatedDeclarations": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
중요한 옵션들의 의미:
declaration: truetsc가 각.js파일 옆에.d.ts파일을 생성하도록 요구합니다. 이것이 이 글의 핵심 옵션입니다.declarationMap: true.d.ts.map파일을 생성합니다. 사용자가 “Go to Definition”을 클릭하면 에디터는.d.ts파일 대신 원본.ts파일을 엽니다. 이 기능이 작동하려면src디렉토리를 함께 publish해야 합니다.module과moduleResolution을NodeNext로 설정하면 TypeScript가 Node.js와 정확히 같은 방식으로 모듈을 분석합니다. 이 방식으로 빌드된 라이브러리는 bundler와 Node.js 모두에서 사용할 수 있습니다.isolatedDeclarations(TypeScript 5.5부터 제공)은 export되는 모든 API가 명시적으로 타입을 선언하도록 강제합니다(예: 함수의 반환 타입). 덕분에 다른 도구들은 type checker를 실행하지 않고도.d.ts를 생성할 수 있습니다. 공개 API도 더 명확해집니다.exclude는 빌드에서 테스트 파일을 제외하여 테스트가dist에 포함되지 않도록 합니다.
2.2. package.json 구성
{
"name": "order-pricing",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "src"],
"scripts": {
"build": "tsc",
"test": "vitest run",
"prepublishOnly": "npm run test && npm run build"
}
}
주의할 점들:
"type": "module"은dist의.js파일을 Node가 ESM으로 인식하게 합니다.exports내에서types조건이 반드시 맨 앞에 와야 합니다. TypeScript는 조건들을 순서대로 검색하고 처음 일치하는 조건에서 멈춥니다.default가 먼저 오면, TypeScript는 절대types에 도달하지 않습니다.- 루트 수준의
main과types는exports를 읽지 않는 레거시 도구를 위한 폴백입니다. files는 npm에 업로드될 항목들을 제한합니다. 이 필드가 없으면node_modules나 내부 설정 파일을 실수로 publish하기 쉽습니다.
3. 라이브러리 코드 작성
3.1. 데이터 타입 정의
// src/types.ts
export type CustomerTier = "regular" | "silver" | "gold";
export interface Customer {
id: string;
name: string;
tier: CustomerTier;
}
export interface OrderItem {
sku: string;
unitPrice: number;
quantity: number;
}
export interface Order {
id: string;
customer: Customer;
items: readonly OrderItem[];
}
items이 readonly OrderItem[]로 선언되어 있습니다. 이렇게 하면 사용자가 일반 배열 또는 읽기 전용 배열을 모두 전달할 수 있습니다. 라이브러리도 사용자의 배열을 수정하지 않겠다는 것을 명확히 합니다.
3.2. 가격 계산 로직
// src/pricing.ts
import type { CustomerTier, Order } from "./types.js";
const DISCOUNT_RATE: Record<CustomerTier, number> = {
regular: 0,
silver: 0.05,
gold: 0.1,
};
/** 할인 전 총 상품 가격, 단위는 VND입니다. */
export function calculateSubtotal(order: Order): number {
return order.items.reduce(
(sum, item) => sum + item.unitPrice * item.quantity,
0,
);
}
/** 고객 등급에 따른 할인이 적용된 총 가격입니다. */
export function calculateTotal(order: Order): number {
const subtotal = calculateSubtotal(order);
const discount = subtotal * DISCOUNT_RATE[order.customer.tier];
return Math.round(subtotal - discount);
}
위 코드에서:
import type은 컴파일러에게 이것이 타입만 가져온다는 것을 알립니다. 이 줄은 빌드 후.js파일에서 완전히 제거됩니다.- 소스 파일이
types.ts이더라도 import 경로는./types.js로 작성합니다.NodeNext설정에서는 이것이 필수입니다. Node.js가 실행할 때.js파일을 찾기 때문입니다. DISCOUNT_RATE은 export되지 않으므로 내부 세부사항이며.d.ts에 나타나지 않습니다./** ... */형식의 주석은.d.ts에서도 유지됩니다. 사용자는 에디터에서 hover할 때 이 내용을 볼 수 있습니다.
3.3. 진입점 (entry point)
// src/index.ts
export { calculateSubtotal, calculateTotal } from "./pricing.js";
export type { Customer, CustomerTier, Order, OrderItem } from "./types.js";
index.ts는 라이브러리의 공개 API를 결정합니다. 데이터 타입도 함께 export해야 합니다. 그렇지 않으면 사용자가 Order 타입을 얻기 위해 Parameters<typeof calculateTotal>[0]를 작성해야 합니다.
4. 빌드 및 .d.ts 파일 확인
npm run build
빌드 후 dist 디렉토리 구조:
dist/
├── index.d.ts
├── index.d.ts.map
├── index.js
├── index.js.map
├── pricing.d.ts
├── pricing.d.ts.map
├── pricing.js
├── pricing.js.map
├── types.d.ts
├── types.d.ts.map
├── types.js
└── types.js.map
dist/pricing.d.ts의 내용:
import type { Order } from "./types.js";
/** 할인 전 총 상품 가격, 단위는 VND입니다. */
export declare function calculateSubtotal(order: Order): number;
/** 고객 등급에 따른 할인이 적용된 총 가격입니다. */
export declare function calculateTotal(order: Order): number;
//# sourceMappingURL=pricing.d.ts.map
이 파일은 서명(signature)만 포함하며 함수 본문은 포함하지 않습니다. DISCOUNT_RATE도 없습니다. dist/types.js는 거의 비어 있습니다(단지 export {};만 남음). interface와 type alias는 런타임에 존재하지 않기 때문입니다.
calculateTotal에서 반환 타입 : number을 빼면, isolatedDeclarations 옵션이 오류를 보고합니다:
src/pricing.ts(20,17): error TS9007: Function must have an explicit return type annotation with --isolatedDeclarations.
이 오류는 실제로 유용합니다. 반환 타입이 자동으로 추론되면, 함수 본문의 작은 변화가 무심코 공개 API를 변경할 수 있기 때문입니다.
5. 런타임 및 타입 테스트
타입이 있는 라이브러리는 두 가지 측면에서 테스트되어야 합니다: 함수가 올바른 값을 반환하는지, 그리고 타입 데이터가 약속한 대로 정확한지입니다. Vitest는 둘 다 지원합니다.
// src/pricing.test.ts
import { describe, expect, expectTypeOf, it } from "vitest";
import { calculateSubtotal, calculateTotal, type Order } from "./index.js";
const goldOrder: Order = {
id: "ORD-001",
customer: { id: "C-01", name: "Lan", tier: "gold" },
items: [
{ sku: "TEA-01", unitPrice: 150_000, quantity: 2 },
{ sku: "CUP-07", unitPrice: 200_000, quantity: 1 },
],
};
describe("calculateTotal", () => {
it("할인 전 총액 계산", () => {
expect(calculateSubtotal(goldOrder)).toBe(500_000);
});
it("금(gold) 등급 고객에게 10% 할인 적용", () => {
expect(calculateTotal(goldOrder)).toBe(450_000);
});
it("약속한 대로 올바른 타입 서명을 가짐", () => {
expectTypeOf(calculateTotal).parameter(0).toEqualTypeOf<Order>();
expectTypeOf(calculateTotal).returns.toBeNumber();
});
it("알 수 없는 고객 등급을 거부함", () => {
// @ts-expect-error "diamond"는 CustomerTier에 속하지 않습니다
const tier: Order["customer"]["tier"] = "diamond";
expect(tier).toBe("diamond");
});
});
각 테스트 분석:
- 처음 두 테스트는 논리를 검증합니다: 2 × 150,000 + 200,000 = 500,000, 10% 할인 후 450,000입니다.
expectTypeOf는 함수의 서명을 고정합니다. 나중에 누군가 반환 타입을string으로 바꾸면 이 테스트는 실패합니다.@ts-expect-error는 다음 줄이 반드시 타입 오류를 일으켜야 한다는 것을 단언합니다. 만약 어느 날"diamond"가 유효해지면, TypeScript는 이 directive가 불필요하다고 보고합니다.
테스트 실행:
npm test
✓ src/pricing.test.ts (4 tests) 3ms
Test Files 1 passed (1)
Tests 4 passed (4)
주의: 기본적으로 Vitest는 코드만 transpile하고 type-check는 하지 않습니다.
@ts-expect-error와 타입 오류는tsc --noEmit -p tsconfig.test.json(테스트 파일을 포함하는 별도 설정) 또는vitest --typecheck를 실행할 때만 검증됩니다. 이 단계를 CI에 포함해야 합니다.
5.1. publish 전 패키지 확인
npm pack --dry-run
npm notice 📦 order-pricing@1.0.0
npm notice Tarball Contents
npm notice 1.1kB dist/index.d.ts
npm notice 312B dist/index.js
...
npm notice 945B src/pricing.ts
npm notice 402B src/types.ts
이 목록을 읽고 모든 .d.ts 파일이 포함되어 있고 테스트 파일이나 비밀 파일이 없는지 확인합니다. 가장 신뢰할 수 있는 검증 방법은 npm pack을 실행한 후 .tgz 파일을 샘플 프로젝트에 설치하고 직접 import해 보는 것입니다. @arethetypeswrong/cli 도구(attw --pack 명령)도 대부분의 exports와 types 설정 오류를 감지할 수 있습니다.
6. 기존 JavaScript 코드에 대해 수동으로 declaration 작성
경우에 따라 라이브러리는 아직 TypeScript로 변환하지 못한 기존 JavaScript 모듈을 유지할 수 있습니다. 이런 모듈을 위해 .d.ts를 수동으로 작성할 수 있습니다.
// src/legacy-tax.js
export function applyVat(amount, rate = 0.08) {
return Math.round(amount * (1 + rate));
}
// src/legacy-tax.d.ts
/**
* 금액에 부가세를 추가합니다.
* @param amount 세전 금액 (VND).
* @param rate 세율, 기본값 0.08.
*/
export declare function applyVat(amount: number, rate?: number): number;
그 다음 index.ts에서 다른 모듈처럼 다시 export합니다:
export { applyVat } from "./legacy-tax.js";
주의:
tsc는 수동으로 작성한.js와.d.ts파일을dist로 자동 복사하지 않습니다. 두 가지 선택지가 있습니다. 첫 번째는allowJs: true를 활성화하여tsc가legacy-tax.js를dist로 복사하도록 한 후, 스크립트로legacy-tax.d.ts도 복사하는 방법입니다(예:"build": "tsc && cp src/legacy-tax.d.ts dist/"). 두 번째는allowJs를 JSDoc과 함께 활성화하여tsc가 JSDoc에서 자동으로.d.ts를 생성하게 하는 방법입니다. 이것은 흔한 실수입니다: 로컬에서는 정상이지만, publish 후 사용자가 모듈 누락 오류를 받습니다.
타입이 없는 써드파티 패키지에 타입을 추가하려면 declare module을 사용합니다:
// src/shims/vn-currency.d.ts
declare module "vn-currency" {
export function format(amount: number): string;
}
이것은 프로젝트 내부에서만 해야 합니다. 라이브러리의 공개 API가 이 shim에 의존하면, 사용자는 이것을 볼 수 없을 것입니다.
7. 흔한 오류들
exports에서default뒤에types가 오는 경우: TypeScript가types에 도달하지 않아 사용자가any또는 TS7016 오류를 받습니다.NodeNext로 import할 때.js확장자를 빼먹는 경우:tsc가 TS2835 오류를 보고합니다.moduleResolution: "bundler"로 변경하여 오류를 피하면, 라이브러리는 bundler에서는 작동하지만 순수 Node.js에서는 깨집니다.devDependencies의 타입 노출:Order가devDependencies의 패키지에 있는 타입을 사용하면,.d.ts파일이 그 패키지를 import합니다. 사용자가 이 패키지를 설치하지 않으면 타입 오류가 발생합니다. 공개 API에 나타나는 모든 타입은dependencies또는peerDependencies에 있어야 합니다.skipLibCheck에 의존: 이 옵션은 빌드를 빠르게 하지만, publish한.d.ts파일 자체의 오류를 숨깁니다. 샘플 프로젝트에서skipLibCheck를 비활성화하고 테스트합니다.- 무분별한 enum 또는 namespace 내보내기:
const enum은isolatedModules를 활성화한 사용자에게 문제를 일으킵니다.CustomerTier과 같은 문자열 union이 보통 더 간단하고 안전합니다. - 패치 버전에서 공개 타입 변경:
Order에 필수 필드를 추가하는 것은 JavaScript 코드가 한 줄도 바뀌지 않았더라도 breaking change입니다..d.ts를 버전 번호 지정 시 API의 일부로 간주합니다.
언제 이 모든 단계를 직접 할 필요가 없을까요? 라이브러리가 ESM과 CommonJS를 모두 내보내야 한다면, tsc를 두 번 구성하는 대신 tsup 또는 tsdown 같은 전용 빌드 도구를 사용하세요. 코드가 monorepo 내부에서만 사용된다면, TypeScript project references가 npm에 publish하는 것보다 더 깔끔한 선택입니다.
8. 결론
이 글은 order-pricing 라이브러리를 처음부터 구축했으며, 주요 단계는 다음과 같습니다:
tsconfig.json에서declaration,declarationMap,isolatedDeclarations을 활성화하여 명확하고 안정적인.d.ts를 생성합니다.package.json에서exports를 선언할 때types조건을 맨 앞에 두고,files로 publish 파일을 제한합니다.- Vitest,
expectTypeOf,@ts-expect-error를 사용하여 런타임과 타입을 모두 테스트합니다. - 기존 JavaScript 코드에 대해 수동으로
.d.ts를 작성하고dist에 포함시킵니다.
가장 중요한 규칙은 .d.ts 파일을 사용자와의 계약으로 간주하는 것입니다. publish하기 전에 npm pack으로 실제 패키지를 확인하세요. 많은 설정 오류는 사용자 쪽에서만 나타나고 당신의 프로젝트에서는 보이지 않기 때문입니다.