파일 하나에 코드를 다 몰아넣던 시절은 지났다. 지금은 기능마다 파일을 쪼개고 import로 엮는다. 타입스크립트는 자바스크립트의 ES 모듈(import/export 문법) 위에 타입을 얹은 것이라, 모듈 자체는 쓰던 그대로다. 다만 타입스크립트에는 남들에겐 없는 파일 종류가 하나 더 있다. 값은 없고 타입 정보만 담은 선언 파일(.d.ts)이다. 남의 자바스크립트 라이브러리에 타입을 입히거나, 전역을 넓히는 게 다 이 파일 몫이다. 아래 코드와 에러는 전부 tsc(타입스크립트 컴파일러)로 확인한 것이다.
export한 것만 밖에서 보인다. 모듈의 기본 규칙은 이 한 줄이다. 파일 안에서 아무리 선언해도 export를 안 붙이면 그 파일 밖에선 존재하지 않는다.
// math.ts
export function add(a: number, b: number) {
return a + b;
}
export const PI = 3.14159;
const secret = 42; // export 안 함
add와 PI는 밖으로 열어줬지만 secret은 안 열었다. 다른 파일에서 secret을 가져다 쓰려 하면 그런 이름 없다고 막힌다.
// main.ts
import { add, PI } from "./math";
console.log(add(1, 2), PI);
console.log(secret);
// error TS2304: Cannot find name 'secret'.
중괄호로 묶어 이름을 그대로 가져오는 걸 이름 있는 가져오기(named import)라 한다. export한 이름과 똑같이 적어야 하고, 안 연 secret은 애초에 목록에 없으니 못 가져온다. 캡슐화가 문법으로 강제되는 셈이다.
default export는 받는 쪽이 이름을 정한다. 파일당 하나만 둘 수 있는 특별한 내보내기가 있다. export default다. 이건 중괄호 없이 받고, 이름도 받는 쪽 마음대로 붙인다.
// multiply.ts
export default function (a: number, b: number) {
return a * b;
}
// main.ts
import mul from "./multiply"; // 이름은 아무거나
console.log(mul(2, 3));
이름 있는 가져오기는 내보낸 이름과 맞춰야 하지만, 기본 내보내기는 받는 쪽에서 mul이든 뭐든 붙일 수 있다. 편해 보여도 파일마다 부르는 이름이 달라져 검색이 어려워지는 탓에, 나는 요즘 웬만하면 이름 있는 내보내기를 쓰고 기본 내보내기는 잘 안 쓴다.
타입 없는 자바스크립트를 들이면 any로 샌다. 문제는 남의 코드를 가져올 때다. 타입 정보가 없는 순수 자바스크립트 파일을 import하면, 타입스크립트가 그 안을 못 읽어 통째로 any가 될 판이라 경고를 띄운다.
// legacy.js (타입 없는 옛 코드)
export function shout(text) {
return text.toUpperCase() + "!";
}
// useLegacy.ts
import { shout } from "./legacy";
// error TS7016: Could not find a declaration file for
// module './legacy'. ... implicitly has an 'any' type.
선언 파일을 못 찾아서 암묵적 any가 된다는 소리다. any로 새면 shout에 뭘 넣든, 반환으로 뭘 하든 검사가 안 돼 타입스크립트를 쓰는 의미가 그 언저리에서 증발한다. 이 구멍을 메우는 게 선언 파일이다.
.d.ts는 값 없이 타입만 적는다. 선언 파일은 확장자가 .d.ts이고, 구현(함수 몸통)은 없이 "이런 이름이 이런 타입으로 존재한다"는 서명만 적는다. 옆의 legacy.js에 짝지어 legacy.d.ts를 두면 된다.
// legacy.d.ts
export function shout(text: string): string;
몸통 대신 세미콜론으로 끝냈다. 이제 shout은 문자열을 받아 문자열을 주는 함수로 알려져, 아까 에러가 사라지고 잘못 쓰면 오히려 걸린다.
import { shout } from "./legacy";
shout(123);
// error TS2345: Argument of type 'number' is not
// assignable to parameter of type 'string'.
구현은 여전히 legacy.js가 하고, 타입 검사만 .d.ts가 맡는다. 값과 타입을 다른 파일로 나눠 든 것이다. 남의 라이브러리를 열어보면 index.js 옆에 index.d.ts가 늘 붙어 있는데, 그게 바로 이 짝이다.
패키지에 타입이 아예 없으면 declare module로 급조한다. npm으로 받은 패키지가 타입도, 별도 타입 패키지도 없을 때가 있다. 그러면 가져오는 순간 모듈을 못 찾는다며 막힌다.
import chalk from "cool-lib";
// error TS2307: Cannot find module 'cool-lib' or its
// corresponding type declarations.
이럴 땐 아무 .d.ts 파일에 declare module로 그 패키지의 모양을 대충이라도 적어주면 된다. declare는 "구현은 딴 데 있고, 타입만 여기서 선언한다"는 표시다.
// shims.d.ts
declare module "cool-lib" {
export default function chalk(text: string): string;
}
이러면 cool-lib이 문자열 하나 받는 함수를 기본 내보내기로 가진 모듈로 인식돼 에러가 풀린다. 정확한 타입이 아니어도, 최소한 any로 새는 것보단 낫다. 급하면 declare module "cool-lib";처럼 몸통 없이 한 줄만 적어 통째로 any 취급하게 막아두는 방법도 있다. 다만 이건 어디까지나 임시방편이라, 유명한 패키지라면 손으로 적기 전에 @types/패키지이름 형태의 타입 전용 패키지가 이미 올라와 있는지부터 찾아보는 게 낫다. 그런 패키지는 커뮤니티가 관리하는 .d.ts 묶음이라, 받아만 두면 import가 알아서 타입을 물어온다.
declare global로 전역을 넓힌다. window에 내 전역 값을 얹는 경우처럼, 이미 있는 전역 타입에 속성을 더 붙여야 할 때가 있다. 그냥 붙이면 그런 속성 없다고 막히니, declare global로 전역 선언을 넓혀준다.
declare global {
interface Window {
myApp: { version: string };
}
}
window.myApp = { version: "1.0" }; // OK
export {};
Window 인터페이스에 myApp을 더해, 원래 있던 Window와 합쳐진다(선언 병합). 맨 아래 export {}는 이 파일을 전역 스크립트가 아니라 모듈로 취급하게 하는 표시인데, declare global은 모듈 안에서만 동작하니 빼먹으면 오히려 에러가 난다. 나도 이 한 줄을 빼먹고 왜 안 되냐며 한참 헤맨 적이 있다.
정리하면 모듈은 export한 것만 밖으로 내보내고 import로 받는 자바스크립트 문법 그대로이고, 선언 파일은 그 위에서 값 없이 타입만 실어 나르는 타입스크립트만의 장치다. 타입 없는 코드에 타입을 입히거나, 전역을 넓히거나, 값과 타입을 분리해 배포하는 일이 전부 .d.ts에서 벌어진다. 남의 패키지가 타입을 안 줘서 막힐 때 당황하지 않는 것만으로도 이 파일의 값어치는 충분하다.