타입 선언 파일 작성 및 사용
타입이 없는 CommonJS 유틸리티에 같은 이름의 선언 파일을 붙여 쓰고, 컴파일러가 선언을 찾는 경로와 Node.js가 구현을 찾는 경로가 따로라는 점을 실습으로 확인한 뒤, 전역 선언과 라이브러리 배포에서 선언 파일을 연결하는 방법을 다룹니다.
앞 글에서 declare로 값의 모양을 적는 문법을 보았습니다. 이 글은 이미 있는 자바스크립트 파일 하나를 골라 그 옆에 선언 파일을 붙이고, 타입스크립트 코드에서 실제로 가져와 실행해 봅니다. 그 과정에서 선언을 찾는 길과 구현을 찾는 길이 어떻게 갈리는지 확인합니다.
이어서 모듈이 아닌 전역 스크립트를 위한 선언과, 타입스크립트로 만든 라이브러리를 배포할 때 선언 파일을 함께 내보내는 방법을 정리합니다.
자바스크립트 유틸리티에 선언 붙이기
다음은 문자열을 다루는 두 함수를 CommonJS 방식으로 내보내는 자바스크립트 파일입니다. 첫 함수는 첫 글자를 대문자로 바꾸고, 둘째 함수는 문자열이 비어 있는지 확인합니다.
function capitalize(str) {
if (typeof str !== "string" || str.length === 0) {
return "";
}
return str.charAt(0).toUpperCase() + str.slice(1);
}
function isEmpty(str) {
return str === null || str === undefined || str.length === 0;
}
module.exports = { capitalize, isEmpty };이 파일에 대한 선언은 같은 폴더에 같은 기본 이름으로 둡니다. 이렇게 짝을 이룬 선언 파일을 사이드카(sidecar) 선언이라 부르며, 컴파일러는 StringUtils를 가져오는 경로를 해석하다가 옆의 StringUtils.d.ts를 찾아 그 타입을 씁니다.
/**
* 첫 글자를 대문자로 바꾼 문자열을 돌려줍니다.
*/
export function capitalize(str: string): string;
/**
* 문자열이 null이거나 undefined이거나 길이가 0이면 true를 돌려줍니다.
*/
export function isEmpty(str: string | null | undefined): boolean;이 선언은 파일의 최상위에서 export하므로 모듈의 선언이며, module.exports 객체가 가진 두 함수를 각각의 이름 있는 내보내기로 적었습니다. isEmpty의 매개변수는 실제 구현이 받아 주는 null과 undefined까지 유니온으로 적었고, /** */ 주석은 편집기에서 함수에 마우스를 올렸을 때 설명으로 보입니다.
가져와서 실행하기
새 폴더 ts-sidecar에 위 두 파일을 저장하고, package.json을 { "type": "commonjs" }로 둔 뒤 다음 파일을 더합니다. app.ts는 선언 파일에 적힌 두 함수를 가져와, capitalize는 한 번, isEmpty는 빈 문자열과 null로 두 번 호출합니다. nullString에 string | null 타입을 붙인 것은 선언이 null도 받아 주는지 검사에서 확인하기 위해서입니다.
import { capitalize, isEmpty } from "./js/StringUtils";
console.log(capitalize("hello world"));
const emptyString = "";
console.log(`"${emptyString}" is empty: ${isEmpty(emptyString)}`);
const nullString: string | null = null;
console.log(`null is empty: ${isEmpty(nullString)}`);{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"noEmitOnError": true
},
"include": ["src/**/*.ts"]
}이 폴더는 CommonJS여서 app.ts도 CommonJS로 출력되므로, 7장에서 본 것과 달리 상대 경로에서 확장자를 생략할 수 있습니다. include의 src/**/*.ts에는 StringUtils.d.ts도 들어가지만, 자바스크립트 파일은 들어가지 않습니다.
tsc -p .
node -e "const fs = require('node:fs'); fs.mkdirSync('dist/js', { recursive: true }); fs.copyFileSync('src/js/StringUtils.js', 'dist/js/StringUtils.js');"
node dist/app.jsHello world
"" is empty: true
null is empty: true둘째 명령은 StringUtils.js를 dist/js에 복사합니다. 자바스크립트 파일은 컴파일의 입력이 아니므로 tsc가 출력 폴더로 옮겨 주지 않는데, 출력된 app.js의 require("./js/StringUtils")는 dist/js에서 구현을 찾기 때문입니다. 복사를 빠뜨린 새 dist에서 실행하면 검사는 통과했는데도 Cannot find module './js/StringUtils'로 멈추며, 예전에 복사해 둔 파일이 남아 있으면 이 누락을 놓칠 수 있습니다.
TYPE / RUNTIME / CONTRACT
같은 import의 타입 경로와 실행 경로를 따로 읽는다
본문 app.ts는 ./js/StringUtils를 가져옵니다. 타입 검사는 같은 폴더의 .d.ts를 사용하고 CommonJS 실행은 실제 .js의 두 함수를 가져옵니다.
같은 가져오기 이름에 타입 해석과 실행 해석이 각각 있습니다. 출력 폴더를 쓸 때 실제 JS도 그 경로에 복사해야 하며 선언 파일이 구현을 출력하지 않습니다.
LOCAL
파일 이름과 공개 export를 맞춘다
본문처럼 같은 기본 이름의 sidecar를 두면 선언이 타입 해석 대상이 됩니다. capitalize는 string 인자를 요구하고 isEmpty는 null·undefined도 허용합니다.
RUN
선언만 복사하면 실행 구현은 없다
출력 코드가 require하는 위치에 StringUtils.js가 필요합니다. Node ESM을 사용하면 package 형식과 실제 파일 확장자도 맞춰야 합니다.
GLOBAL
전역 선언은 프로젝트에 포함한다
global.d.ts의 MY_APP_NAME, VERSION_NUMBER, logActivity, Analytics와 Window 확장은 files/include 등으로 포함합니다. 실제 전역 구현은 먼저 로드해야 합니다.
PACKAGE
패키지 진입점은 메타데이터로 연결한다
my-awesome-library의 main은 dist/index.js, types는 dist/index.d.ts, files는 dist를 배포 대상으로 지정합니다. import한 모듈과 전역 포함은 같은 경로가 아닙니다.
상대 파일 해석은 설정과 확장자에 따라 후보가 달라집니다. 같은 이름의 최상위 export sidecar를 상대 경로 ambient module 선언으로 대체하지 않습니다.
위 그림처럼 같은 import 문 하나가 두 갈래로 나뉩니다. 컴파일러는 ./js/StringUtils에 .ts, .tsx, .d.ts를 차례로 붙여 보다가 StringUtils.d.ts를 찾아 타입을 검사하고, 실행할 때 Node.js는 출력된 require의 경로로 dist/js/StringUtils.js를 찾아 실제 함수를 불러옵니다. 두 경로가 각각 맞는 파일을 가리켜야 검사와 실행이 함께 성공하며, 이 확장자의 대응 규칙은 공식 모듈 참조에 있습니다.
선언과 구현의 차이 보기
선언은 구현이 실제로 받아 주는 값의 일부만 공개할 수 있습니다. 예를 들어 자바스크립트의 capitalize는 문자열이 아닌 값을 받으면 빈 문자열을 돌려주도록 방어하고 있지만, 선언은 string만 받는다고 적었습니다.
console.log(JSON.stringify(capitalize("")));
console.log(isEmpty(" "), isEmpty(undefined));
// capitalize(123);""
false true빈 문자열은 구현의 첫 조건에 걸려 빈 문자열을 돌려주고, JSON.stringify로 감싸 출력했으므로 따옴표 두 개가 보입니다. isEmpty는 길이가 0인지만 보고 공백을 잘라 내지 않으므로, 공백 세 칸은 비어 있지 않은 것으로 판정됩니다. 주석을 풀면 숫자가 선언의 string에 맞지 않아 TS2345가 나오는데, 이것은 구현이 숫자를 받아 줄 수 있다는 것과 별개로 공개하는 사용법을 문자열로 정한 결정입니다.
전역 스크립트를 위한 선언
<script> 태그로 불러오는 예전 코드처럼 모듈 없이 전역에 값을 만드는 코드에는, 최상위에 export가 없는 선언 파일을 씁니다. 이런 파일에서는 이미 있는 전역 인터페이스에 속성을 더할 수도 있습니다.
declare var MY_APP_NAME: string;
declare const VERSION_NUMBER: number;
declare function logActivity(message: string, level: "info" | "warn" | "error"): void;
interface Window {
myGlobalData?: {
userId: number;
sessionId: string;
};
}
declare namespace Analytics {
function trackEvent(eventName: string, data?: object): void;
}interface Window는 브라우저의 타입인 lib.dom.d.ts의 Window와 이름이 같으므로, 2장에서 본 선언 병합으로 합쳐져 window.myGlobalData를 쓸 수 있게 됩니다. 이 선언이 포함된 브라우저용 코드에서는 logActivity("User logged in", "info")가 검사를 통과하지만, logActivity("Invalid level", "debug")는 Argument of type '"debug"' is not assignable to parameter of type '"info" | "warn" | "error"'.로 진단됩니다. 앞 글과 마찬가지로 이 값들을 실제로 만드는 스크립트는 선언과 별도로 먼저 불러와야 합니다.
라이브러리와 함께 배포하기
타입스크립트로 만든 라이브러리는 선언 파일을 손으로 쓰지 않고, declaration 옵션으로 컴파일할 때 함께 만듭니다. 그러면 outDir에 index.js와 함께 index.d.ts가 생기고, 공개한 함수와 타입의 선언이 그 안에 들어갑니다.
{
"name": "my-awesome-library",
"version": "1.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": ["dist"]
}main과 types는 예전 방식의 진입점이고, exports는 지금의 Node.js와 컴파일러가 먼저 읽는 진입점입니다. exports 안에서는 types 조건을 가장 앞에 두어 컴파일러가 선언 파일을 먼저 찾게 하고, files는 npm에 올릴 폴더를 dist로 정합니다. 앞의 StringUtils.d.ts처럼 손으로 쓴 선언은 declaration 옵션이 출력 폴더로 옮겨 주지 않으므로, 배포한다면 구현과 함께 실제 출력 폴더에 넣는 절차를 따로 둡니다.
TYPE / RUNTIME / CONTRACT
StringUtils의 실제 동작을 선언하고 소비 코드로 확인한다
기존 CommonJS 파일의 두 함수를 관찰해 같은 이름의 .d.ts를 작성합니다. 올바른 호출·잘못된 인자·실제 결과를 함께 확인하고 API 변경 때 구현과 선언을 갱신합니다.
원 구현의 공개 API를 사람이 sidecar에 기술합니다. app은 선언으로 타입을 검사하고 실제 JS를 호출합니다. 두 확인은 별도 경로이며 직접 작성한 선언을 자동 생성 결과와 혼동하지 않습니다.
1 · IMPLEMENTATION
실제 두 함수의 계약을 읽는다
StringUtils.js는 module.exports로 capitalize와 isEmpty를 내보냅니다. capitalize는 첫 글자를 대문자로 바꾸고 isEmpty는 null·undefined·빈 문자열을 검사합니다.
2 · SIDECAR
같은 폴더에 공개 시그니처를 쓴다
src/js/StringUtils.d.ts의 최상위 export로 capitalize(string):string과 isEmpty(string|null|undefined):boolean을 기술합니다. 함수 구현을 다시 작성하지 않습니다.
3 · VERIFY
타입과 실제 결과를 확인한다
app.ts의 출력은 Hello world, 빈 문자열 검사 true, null 검사 true입니다. capitalize(123)은 선언을 사용하는 타입 검사에서 오류입니다. 실행은 실제 .js를 필요로 합니다.
4 · MAINTAIN
작성 방식에 맞춰 갱신하고 배포한다
이 로컬 JavaScript sidecar는 직접 갱신합니다. 별도 TypeScript 라이브러리는 declaration으로 생성하고 package main/types와 배포 파일을 맞춥니다. 두 방식을 같은 생성 단계로 합치지 않습니다.
JavaScript의 JSDoc으로 타입을 얻는 방법도 있습니다. 여기서는 구현 옆의 직접 작성한 .d.ts로 공개 계약을 제공하는 예제입니다.
위 그림은 이 글의 흐름을 한 바퀴로 정리한 것입니다. 사람이 구현의 공개된 동작을 관찰해 선언을 쓰고, 사용하는 코드로 올바른 호출과 잘못된 호출, 실제 결과를 함께 확인하며, 구현의 사용법이 바뀌면 선언도 함께 고칩니다. 손으로 쓴 선언은 자동으로 생성된 선언과 달리 구현이 바뀌어도 저절로 따라 바뀌지 않는다는 점을 기억해야 합니다.
더 깊이: 타입 검사 없이 선언 파일을 만드는 isolatedDeclarations
declaration 옵션으로 선언 파일을 만들려면 보통 타입 검사기가 필요합니다. 반환 타입을 적지 않은 함수의 선언을 쓰려면, 그 함수가 부르는 다른 파일의 함수까지 따라가 반환 타입을 추론해야 하기 때문입니다. 그래서 자바스크립트를 빠르게 출력하는 도구는 많아도, 선언 파일을 빠르게 만드는 도구는 만들기 어려웠습니다. TypeScript 5.5 릴리스 노트는 선언 파일을 병렬로 빠르게 만들 수 있다면, 모노레포의 여러 패키지도 그 선언을 바탕으로 곧바로 병렬로 검사할 수 있다는 점을 동기로 듭니다.
TypeScript 5.5의 isolatedDeclarations는 타입 검사기 없이는 선언으로 바꿀 수 없는 파일을 오류로 알려 줍니다. 쉽게 말해 내보내는 함수와 변수에 타입이 충분히 적혀 있지 않으면 진단하므로, 다른 함수의 결과를 돌려주는 내보낸 함수에는 반환 타입을 직접 적어야 합니다. export let x = 10처럼 타입이 바로 보이는 식과 내보내지 않는 지역 선언에는 표기가 필요 없고, 이 옵션은 declaration이나 composite와 함께 켜야 합니다. 출력 자체는 바꾸지 않고 오류를 보고하는 방식만 바꾸며, 표기의 부담이 늘어나므로 릴리스 노트는 프로젝트마다 따져 보고 들이라고 권합니다.
정리
- 자바스크립트 파일 옆에 같은 기본 이름의
.d.ts를 두면, 컴파일러가 그 경로를 해석하다가 선언을 찾아 타입을 검사합니다. - 컴파일러는 선언 파일로 타입을, Node.js는 출력된
require나import의 경로로 구현을 찾으므로, 두 경로가 각각 맞는 파일을 가리켜야 합니다. - 컴파일의 입력이 아닌 자바스크립트 파일은
tsc가 출력 폴더로 옮기지 않으므로, 구현을 실행할 위치에 따로 준비합니다. - 선언은 구현이 받아 주는 값의 일부만 공개할 수 있으며, 전역 선언 파일에서는
interface Window처럼 기존 전역 인터페이스를 병합으로 넓힐 수 있습니다. - 라이브러리는
declaration으로 선언을 생성하고,package.json의exports에서types조건을 앞에 두어 구현과 선언의 경로를 함께 알립니다.
연습 문제
1. 더한 함수의 선언
StringUtils.js에 function repeat(str, count) { return str.repeat(count); }를 더하고 module.exports에도 넣었습니다. 선언 파일에는 무엇을 더해야 하며, 더하지 않으면 app.ts에서 repeat를 가져올 때 어떻게 되나요?
해설 보기
export function repeat(str: string, count: number): string;을 선언 파일에 더합니다. 더하지 않으면 컴파일러는 선언 파일만 보고 판단하므로, import { repeat }에서 그런 이름의 내보내기가 없다는 진단이 나옵니다. 구현에 함수가 있어도 선언이 그것을 알리지 않으면 쓸 수 없으므로, 손으로 쓴 선언은 구현과 함께 고쳐야 합니다.
2. 복사를 빠뜨린 실행
dist 폴더를 지우고 tsc -p .만 실행한 뒤 node dist/app.js를 실행하면 어떻게 되나요? 이 문제를 컴파일 단계에서 잡을 수 없는 이유는 무엇인가요?
해설 보기
dist/js/StringUtils.js가 없으므로 Node.js가 Cannot find module './js/StringUtils'로 멈춥니다. 컴파일러는 선언 파일만 확인하면 검사를 마칠 수 있고, 출력 폴더에 구현이 있는지는 확인하지 않기 때문에 이 누락을 알 수 없습니다. 그래서 빌드 절차에 구현을 복사하는 단계를 넣거나, 실행 결과를 확인하는 테스트로 이런 누락을 잡습니다.
3. 선언을 넓힐지 정하기
자바스크립트의 capitalize는 숫자를 받으면 빈 문자열을 돌려줍니다. 선언의 매개변수를 str: unknown으로 넓히면 무엇이 좋아지고 무엇을 잃나요?
해설 보기
unknown으로 넓히면 구현이 실제로 받아 주는 모든 값을 선언이 그대로 반영하므로, 바깥에서 들어온 값을 검사 없이 넘겨도 진단이 나지 않습니다. 대신 capitalize(123)처럼 숫자를 넘기는 실수를 컴파일러가 더는 알려 주지 않고, 빈 문자열이 조용히 돌아오게 됩니다. 공개 타입은 구현이 견딜 수 있는 범위가 아니라 사용하는 쪽에 허용할 범위로 정하는 것이 보통입니다.
다음 글 앰비언트 모듈 선언에서는 파일 하나에 묶이지 않고 이름으로 모듈을 선언하는 방법과, 모듈 안에서 전역을 넓히는 declare global을 다룹니다.