TypeScript 매뉴얼
TypeScript 매뉴얼: 입문부터 고급까지
요약
TypeScript는 JavaScript에 정적 타입을 추가한 프로그래밍 언어로, 대규모 프로젝트에서도 안정적이고 유지보수하기 쉬운 코드를 작성할 수 있게 해줍니다. 이 매뉴얼에서는 TypeScript의 기본 문법과 타입 시스템부터 고급 타입 활용법까지 폭넓게 다루며, 컴파일러 설정과 모듈 시스템, 선언 파일, React 및 Node.js와의 통합, 프로젝트 도입 전략, 그리고 자주 발생하는 오류 해결과 유용한 도구에 대한 내용을 포함합니다. 초보자도 이해할 수 있도록 상세한 설명과 함께, 중급·고급 개발자에게도 도움이 될 심화 내용과 실전 예제를 풍부하게 제공합니다.
목차
TypeScript란 무엇인가
TypeScript는 Microsoft에서 개발한 오픈 소스 프로그래밍 언어로, JavaScript에 선택적인 정적 타입 문법을 추가한 상위 호환(Superset)입니다. 즉, 모든 기존 JavaScript 코드가 유효한 TypeScript 코드이며, TypeScript로 작성된 코드는 컴파일(트랜스파일)되어 일반 JavaScript로 변환됩니다. 2012년 10월 처음 공개되었으며, C#을 만든 Anders Hejlsberg가 개발에 참여하였습니다.
TypeScript의 가장 큰 특징은 _정적 타입 검사_를 통해 개발 단계에서 오류를 잡아낼 수 있다는 것입니다. 변수나 함수 등에 타입을 지정하면, 잘못된 타입의 값이 사용될 경우 컴파일 시점에 오류를 발생시켜 실행 전에 문제를 발견할 수 있습니다. 또한 타입 정보를 통해 IDE의 자동 완성과 리팩토링 등의 도구 지원이 향상되어 개발 생산성이 높아집니다. 결과적으로 코드의 가독성과 유지보수성이 향상되며, 규모가 큰 프로젝트에서 발생하기 쉬운 버그를 줄여줍니다.
예를 들어, JavaScript에서는 객체 프로퍼티 이름이나 함수 매개변수를 잘못 써도 런타임에야 오류가 발견되지만, TypeScript에서는 컴파일 단계에서 이러한 오류를 바로 잡을 수 있습니다. 물론 옵셔널(optional) 타입 시스템이므로 필요한 경우 일부 코드에 타입을 적용하지 않거나 any 타입을 사용하여 유연하게 처리할 수도 있습니다. TypeScript는 기본적으로 최신 ECMAScript 기능도 지원하여, 클래스 문법이나 비동기/await와 같은 기능을 사용하면서도 구형 환경을 위해 ES5 등으로 트랜스파일할 수 있습니다.
요약하면, TypeScript는 JavaScript의 모든 장점을 유지하면서 타입 시스템을 도입하여 대규모 애플리케이션 개발을 더욱 견고하게 해주는 도구입니다. 다음 섹션부터는 TypeScript의 구체적인 문법과 기능들을 살펴보겠습니다.
기본 문법과 타입 시스템
TypeScript의 기본 문법은 JavaScript와 대부분 유사하지만, 정적 타입 표기와 몇 가지 추가 기능이 있습니다. 이 장에서는 변수 선언, 함수 정의, 인터페이스, 제네릭, 유니언/인터섹션 타입 등의 기본적인 타입 시스템 개념을 소개합니다. 또한 TypeScript의 타입 추론이 어떻게 동작하는지도 함께 살펴봅니다.
변수와 기본 타입
JavaScript와 마찬가지로 var, let, const 키워드를 사용해 변수를 선언할 수 있지만, TypeScript에서는 변수에 타입 주석(type annotation)을 달아 타입을 지정할 수 있습니다:
let message: string = "Hello, TypeScript";
const age: number = 27;
var isOpen: boolean = true;
위의 예에서 message는 문자열(string), age는 숫자(number), isOpen은 불리언(boolean) 타입으로 선언되었습니다. TypeScript의 기본 제공 원시 타입으로는 string, number, boolean, null, undefined, symbol, bigint 등이 있으며, 자바스크립트의 모든 객체 타입도 사용할 수 있습니다. 또한 배열 타입은 number[] (숫자 배열) 혹은 Array<number>와 같이 표기할 수 있고, 튜플(tuple) 타입을 사용하면 정해진 개수와 타입 순서를 가진 배열을 표현할 수도 있습니다 (예: let tuple: [string, number] = ["apple", 5];).
TypeScript에서는 타입 주석을 생략할 수도 있는데, 이 경우 타입 추론에 의해 변수의 타입이 자동 결정됩니다. 예를 들어:
let count = 10; // 타입 추론에 의해 count는 number로 간주됨
const greeting = "안녕하세요"; // greeting은 string으로 추론
초기값을 할당하면 그 값을 기반으로 타입을 추론하므로, 굳이 명시적으로 타입을 적지 않아도 됩니다. 다만, 선언과 동시에 초기화를 하지 않으면 암시적으로 any 타입이 될 수 있으므로(컴파일러 설정에 따라 경고 발생) 필요한 경우 타입을 명시해야 합니다:
let value; // 타입 선언 없음: 기본적으로 any로 취급 (strict 모드에서는 에러)
value = 123;
value = "문자열"; // any 타입이므로 어떤 값도 할당 가능
위 코드에서 value는 any로 간주되어 숫자도 문자열도 담을 수 있습니다. any는 모든 타입을 허용하는 특수 타입으로, 컴파일러의 타입 검사를 일시적으로 우회할 수 있습니다. 하지만 any를 남용하면 타입 검사의 이점을 잃게 되므로, 가능하면 any 대신 안전한 unknown 타입이나 정확한 타입을 사용하는 것이 좋습니다.
함수와 매개변수 타입
JavaScript 함수도 TypeScript에서는 함수의 매개변수와 반환 타입에 타입을 지정할 수 있습니다. 함수 선언 시 각 매개변수 뒤에 : 타입 형태로 지정하고, 화살표 오른쪽에 반환 타입을 명시합니다:
function add(x: number, y: number): number {
return x + y;
}
위 함수 add는 number 타입 인자 두 개를 받아 number를 반환하도록 명시되어 있습니다. 만약 함수에서 잘못된 타입을 반환하거나, 잘못된 타입의 인자를 전달하면 컴파일 오류가 발생합니다:
add(1, 2); // 정상 호출, 결과는 3
add(1, "2"); // 오류: string 타입은 매개변수 y의 number 타입에 할당될 수 없습니다.
반환 타입 주석은 선택 사항으로, 명시하지 않으면 타입 추론에 의해 함수 본문의 return으로 추론된 타입이 사용됩니다. 예를 들어 위 add 함수의 반환 타입 number는 사실 명시하지 않아도 x + y의 결과로부터 자동 추론됩니다. 하지만 함수가 복잡해지거나 명확한 반환 타입이 필요한 경우에는 명시하는 것이 좋습니다.
선택적 매개변수(optional parameter)와 기본값이 있는 매개변수(default parameter)도 지원합니다. 선택적 매개변수는 이름 뒤에 ?를 붙이며, 호출 시 생략이 가능하고, 함수 내부에서는 해당 인자가 undefined일 수 있음을 염두에 두어야 합니다:
function greet(name: string, greeting?: string): string {
if (greeting) {
return `${greeting}, ${name}!`;
} else {
return `Hello, ${name}!`;
}
}
greet("TypeScript"); // "Hello, TypeScript!"
greet("TypeScript", "안녕하세요"); // "안녕하세요, TypeScript!"
위 예에서 greeting 매개변수는 선택 사항이며 제공되지 않을 수도 있으므로, 함수 본문에서 존재 여부를 체크하고 있습니다. 기본값이 있는 매개변수는 일반 JavaScript와 동일하게 함수 선언에서 값을 할당하면 되며, 기본값이 있는 경우 암시적으로 해당 매개변수는 선택적(optional)으로 간주됩니다. 예:
function log(message: string, level: string = "info") {
console.log(`[${level}] ${message}`);
}
log("로그 메시지"); // [info] 로그 메시지
log("중요 메시지", "warn"); // [warn] 중요 메시지
Rest 파라미터도 ...args: 타입[] 형태로 지원하여 가변 인자 함수를 정의할 수 있습니다. 또한 함수 자체를 변수에 담는 경우, 함수 타입을 별도로 작성할 수도 있습니다:
// 함수 타입 선언: 두 개의 number를 받아 number를 반환하는 함수
let operate: (a: number, b: number) => number;
operate = add;
operate(2, 3); // 5, add 함수가 호출됨
operate = (x: number, y: number): number => x * y;
operate(2, 3); // 6, 다른 구현체
이처럼 함수도 하나의 타입으로 표현되며, 콜백 함수나 함수 인수를 다룰 때 유용하게 사용할 수 있습니다.
인터페이스와 타입 별칭
인터페이스(interface)는 TypeScript에서 객체의 구조를 정의하는 데 사용되는 문법입니다. 자바 또는 C#의 인터페이스와 유사하게, 주로 객체가 가져야 할 프로퍼티 목록과 타입을 기술하는 용도로 쓰입니다:
interface User {
id: number;
name: string;
email?: string; // 물음표(?)가 붙으면 선택적 프로퍼티
}
위 User 인터페이스는 id, name, email 프로퍼티를 갖는 객체 구조를 정의합니다. email에 ?를 붙였으므로 email 프로퍼티는 있어도 되고 없어도 됩니다. 이 인터페이스를 사용하면 다음과 같이 객체의 타입으로 활용할 수 있습니다:
function getUserName(user: User): string {
return user.name;
}
const u: User = { id: 1, name: "Alice" };
console.log(getUserName(u)); // "Alice"
u 객체는 User 인터페이스를 준수하므로 getUserName 함수에 인자로 전달될 수 있습니다. 만약 User에 정의되지 않은 프로퍼티가 있거나, 필수 프로퍼티가 빠져있으면 컴파일 오류가 발생합니다. 인터페이스는 객체의 읽기 전용 속성도 정의할 수 있는데, 프로퍼티 앞에 readonly를 붙이면 해당 필드는 한 번 설정 후 변경할 수 없습니다:
interface Point { readonly x: number; readonly y: number; }
const p: Point = { x: 10, y: 20 };
p.x = 5; // 오류: 읽기 전용 속성은 변경할 수 없음
또한 인터페이스는 메서드를 가질 수 있어 객체가 구현해야 할 함수 형태를 지정할 수도 있고, 인덱스 시그니처(index signature)를 사용하여 동적으로 프로퍼티 키를 표현할 수도 있습니다. 예를 들어 [key: string]: number라고 인터페이스에 지정하면 임의의 문자열 키에 숫자 값을 갖는 객체를 표현합니다.
TypeScript에는 인터페이스와 유사하지만 더 범용적으로 타입을 정의하는 타입 별칭(type alias)도 있습니다. type 키워드를 사용하여 임의의 타입에 이름을 붙일 수 있는데, 인터페이스가 주로 객체 구조를 표현하는 데 반해 타입 별칭은 원시 타입, 유니언, 튜플 등 어떤 타입이든 별칭을 붙일 수 있다는 점이 특징입니다:
type ID = number | string; // ID는 number 또는 string 타입을 의미
let userId: ID;
userId = 101;
userId = "admin"; // 둘 다 허용
type Point2D = { x: number, y: number };
type Point3D = Point2D & { z: number }; // 기존 타입을 확장하여 새로운 타입 생성
위에서 ID라는 타입 별칭은 number | string 유니언 타입으로 정의되었고, Point3D는 Point2D 타입에 z가 추가된 인터섹션 타입으로 정의되었습니다. 이런 식으로 타입 별칭을 사용하면 기존에 정의된 타입 조합에도 이름을 부여하여 가독성을 높일 수 있습니다.
인터페이스 vs 타입 별칭: 대부분의 경우 인터페이스와 타입 별칭은 객체 구조를 정의하는 용도로 상호 교환 가능하지만, 몇 가지 차이가 있습니다. 인터페이스는 선언 병합이 가능하여 동일한 이름의 인터페이스를 여러 번 선언하면 자동으로 합쳐지지만, 타입 별칭은 동일 이름으로 중복 선언할 수 없습니다. 반면 타입 별칭은 유니언, 튜플 같은 복잡한 타입에도 이름을 부여할 수 있다는 장점이 있습니다. 또한 타입 별칭은 extends 키워드를 통해 상속할 수 없지만, 인터페이스는 다른 인터페이스를 extends하여 확장할 수 있습니다. 프로젝트의 성격과 팀 스타일에 따라 인터페이스와 타입 별칭을 적절히 활용하면 됩니다.
제네릭(Generic)
제네릭(Generic)은 타입을 함수나 클래스의 매개변수처럼 활용할 수 있게 해주는 문법입니다. 제네릭을 사용하면 클래스나 함수의 동작을 정의할 때 구체적인 타입을 명시하지 않고, 추후 사용할 때 결정하도록 일반화할 수 있습니다. 예를 들어, 인자로 받은 값을 그대로 반환하는 함수 identity를 만든다고 할 때, 반환 타입은 입력 타입과 같아야 합니다. 제네릭을 사용하지 않는다면 모든 타입을 포괄하기 위해 any를 쓸 수도 있지만, 그러면 타입 안전성이 떨어집니다. 이를 제네릭으로 구현하면:
function identity<T>(value: T): T {
return value;
}
여기서 <T>가 바로 제네릭 타입 매개변수를 선언한 것입니다. identity 함수는 호출될 때 타입 매개변수 T가 구체적으로 어떤 타입인지 결정됩니다. 사용 방법은:
let result1 = identity<string>("hello"); // 명시적으로 T를 string으로 지정
let result2 = identity(123); // 타입 추론에 의해 T를 number로 간주
result1은 "hello"를 입력했으므로 T가 string으로 확정되어 반환 타입도 string이고, result2는 123(number)이 들어갔으므로 T가 number로 추론되어 반환 타입이 number가 됩니다. 이처럼 제네릭 함수는 다양한 타입에 대해 동작을 하나의 구현으로 일반화할 수 있게 해주며, 호출 시점에 타입이 안전하게 결정되기 때문에 편리합니다.
제네릭은 클래스와 인터페이스에서도 사용 가능합니다. 예를 들어, 특정 타입의 값을 저장하는 박스 객체를 제네릭 클래스로 정의하면:
class Box<T> {
contents: T;
constructor(value: T) {
this.contents = value;
}
}
const box1 = new Box<string>("Seoul");
const box2 = new Box<number>(100);
Box<T> 클래스는 생성 시점에 타입 매개변수 T를 받으며, contents 프로퍼티가 그 T 타입으로 정의됩니다. box1은 T를 string으로 지정하여 문자열을 담는 박스가 되고, box2는 number 박스가 됩니다. 이 박스 클래스는 내부 구현을 하나로 유지하면서도 다양한 타입을 안전하게 다룰 수 있습니다.
또한 제네릭 제약(Generic Constraints)을 통해 타입 매개변수에 제한을 걸 수 있습니다. 예를 들어, 전달된 인자의 .length 프로퍼티를 출력하는 함수를 제네릭으로 작성한다고 할 때, 임의의 타입 T를 받아서는 .length가 있는지 알 수 없으므로 에러가 발생합니다. 이 경우 T가 .length: number 프로퍼티를 가지고 있는 타입이어야 한다는 제약을 줄 수 있습니다:
function logLength<T extends { length: number }>(arg: T): void {
console.log(arg.length);
}
logLength("hello"); // 문자열은 length 프로퍼티가 있으므로 OK (출력: 5)
logLength([1, 2, 3]); // 배열도 length 존재 (출력: 3)
logLength(123); // 오류: number에는 length 프로퍼티가 없음
위 예에서 <T extends { length: number }> 제네릭 제약은 T가 반드시 length 속성을 가져야 함을 뜻합니다. 그래서 문자열이나 배열처럼 length가 있는 타입은 호출 가능하지만, 숫자처럼 length가 없는 타입으로는 함수를 호출할 수 없습니다.
제네릭은 TypeScript의 타입 시스템을 강력하고 유연하게 만들어주는 중요한 도구로, 컬렉션 처리, 재사용 가능한 컴포넌트 작성, 라이브러리 설계 등에 널리 활용됩니다.
유니언과 인터섹션 타입
유니언 타입(Union Type)은 여러 타입 중 하나를 가질 수 있는 타입을 의미합니다. 파이프 기호(|)를 사용하여 표현하며, 예를 들어 string | number는 문자열 또는 숫자가 될 수 있음을 나타냅니다. 유니언 타입은 함수 오버로딩을 대신하거나, 하나의 변수에 여러 형태의 값이 할당될 수 있을 때 사용하면 편리합니다:
function formatValue(value: string | number): string {
if (typeof value === "number") {
// value를 number로 처리
return value.toFixed(2);
} else {
// value를 string으로 처리
return value.toUpperCase();
}
}
let result = formatValue(42); // "42.00"
result = formatValue("hello"); // "HELLO"
위 formatValue 함수는 인자로 문자열이나 숫자를 받을 수 있습니다. 함수 내부에서는 typeof 체크를 통해 전달된 값의 실제 타입을 좁히고(narrowing) 그에 맞는 처리를 하고 있습니다. 이러한 타입 가드(type guard)로 TypeScript는 유니언 타입의 변수 value가 런타임에 어떤 타입인지 유추하고, 해당 분기 내에서는 그 구체 타입으로 간주하여 잘못된 프로퍼티 접근이나 연산을 막아줍니다. (예를 들어 value가 문자열인 분기에서는 문자열 메서드인 toUpperCase()를 사용할 수 있고, 숫자 분기에서는 toFixed()를 사용할 수 있습니다.)
유니언 타입은 다양한 상황에서 활용됩니다. 함수 반환 값이 몇 가지 경우 중 하나일 수 있을 때, 또는 객체 프로퍼티가 서로 다른 타입을 가질 수 있을 때 등을 표현할 수 있습니다. 또한 리터럴 타입(literal type)과 결합하면 정해진 몇 가지 값만 가질 수 있는 상태를 정의하기 좋습니다:
type Direction = "left" | "right" | "up" | "down";
let dir: Direction;
dir = "left"; // OK
dir = "north"; // 오류: "north"는 Direction 타입에 속하지 않음
위에서 Direction 타입은 네 가지 문자열 값 중 하나만 가질 수 있는 유니언으로 정의되었습니다. 이처럼 유니언은 코드에서 특정 값 집합만 허용하도록 제약함으로써 더 안정적인 코드를 작성할 수 있게 합니다.
인터섹션 타입(Intersection Type)은 앰퍼샌드(&) 기호를 사용하며, 여러 타입을 모두 만족하는 타입을 말합니다. 인터섹션은 주로 객체 타입을 결합할 때 사용되며, 두 개 이상의 타입의 프로퍼티를 모두 가지는 복합 타입을 만들어냅니다:
interface Person { name: string; }
interface Timestamped { createdAt: Date; }
type UserInfo = Person & Timestamped;
const user: UserInfo = {
name: "Bob",
createdAt: new Date()
};
위 UserInfo 타입은 Person과 Timestamped 인터페이스를 인터섹션으로 합친 것으로, name과 createdAt 두 프로퍼티를 모두 가져야 합니다. 그래서 user 객체를 만들 때 두 속성을 모두 제공해야 타입이 만족됩니다. 인터섹션 타입은 여러 객체 타입을 합쳐 하나로 표현하거나, 인터페이스 다중 상속과 유사한 효과를 낼 때 유용합니다.
유니언과 인터섹션은 각각 "OR", "AND" 개념으로 이해하면 쉬운데, 유니언은 값이 이 타입이거나 저 타입일 수 있다는 의미이고, 인터섹션은 해당 타입이면서 동시에 다른 타입이기도 해야 한다는 뜻입니다. 상황에 따라 유니언과 인터섹션을 적절히 활용하면 복잡한 타입 관계도 정확하게 표현할 수 있습니다.
타입 추론
TypeScript의 타입 추론(type inference)은 코드의 문맥으로부터 자동으로 타입을 유추하는 기능입니다. 이를 통해 개발자가 모든 곳에 일일이 타입 주석을 달지 않아도, 대부분의 경우 컴파일러가 똑똑하게 타입을 지정해줍니다. 예를 들어 변수 선언 시 초기값이 있으면 그 값을 기반으로 타입이 추론되고, 함수 반환값도 return 문을 분석하여 타입이 결정됩니다.
간단한 예를 보겠습니다:
let num = 12; // num은 number로 추론
const city = "Seoul"; // city는 string으로 추론
function multiply(x: number, y: number) {
return x * y;
}
let result = multiply(5, 10);
위에서 num은 12라는 리터럴 값을 보고 number로, city는 "Seoul"을 보고 string으로 타입이 결정됩니다. multiply 함수는 매개변수 타입이 명시되어 있고 반환 타입 주석이 없지만, x * y의 결과가 숫자이므로 반환 타입이 number로 추론됩니다. 따라서 result 변수도 number 타입으로 간주되어 이후 result에 숫자가 아닌 값을 할당하려 하면 오류가 발생합니다.
타입 추론은 함수 호출 시 제네릭 타입 매개변수도 유추합니다. 위의 identity 함수 예시에서 명시적으로 <string>을 쓰지 않아도 "hello"를 넣으면 T가 string으로 추론되는 것이 그 예입니다. 또 다른 예로 배열의 고차 함수에서도 추론이 강력하게 동작합니다:
const numbers = [1, 2, 3, 4];
const doubled = numbers.map(num => num * 2);
// numbers는 number[]로 추론
// map의 콜백 매개변수 num은 number로 추론, doubled는 number[]로 추론
이처럼 .map 메서드의 타입 정의를 통해 콜백 num의 타입이 자동으로 number로 잡히고, 반환된 배열 doubled의 타입도 number[]로 알고 있기 때문에 이후 doubled를 다른 타입과 혼동하는 일을 방지합니다.
타입 추론이 잘 동작하기 때문에 작은 프로젝트나 간단한 변수들에 대해서는 타입 표기를 생략하고도 많이 코드를 작성합니다. 그러나 복잡한 객체나 제네릭을 다룰 때는 추론만으로 부족한 경우도 있습니다. 이때는 개발자가 타입을 명시적으로 지정하여 컴파일러에게 의도를 알려줄 수 있습니다. 또한 타입 단언(type assertion)을 사용하면 컴파일러가 추론한 타입보다 개발자가 더 구체적인 타입을 알고 있다고 주장할 수도 있지만 (값 as 구체타입 형태), 이는 잘못 사용할 경우 런타임 오류를 야기할 수 있으므로 꼭 필요한 경우에만 사용해야 합니다.
TypeScript의 타입 추론은 대부분의 경우 옳은 방향으로 동작하지만, 가끔 복잡한 경우에는 추론 결과가 원하는 타입과 다를 수도 있습니다. 이러한 상황에서는 타입 구문을 명시하여 예상치 못한 추론을 바로잡을 수 있습니다. 전반적으로 타입 추론을 신뢰하되, 언제 명시적 타입이 필요한지 균형을 잡는 것이 중요합니다.
고급 타입 활용법
TypeScript의 강력함은 고급 타입 시스템에서 빛을 발합니다. 기본적인 타입 외에도 조건부 타입, 맵드 타입, 유틸리티 타입 등을 사용하면 복잡한 타입 논리를 구현하고, 라이브러리나 프레임워크를 타입 안전하게 설계할 수 있습니다. 이 장에서는 이러한 고급 타입 기능들을 간략히 살펴봅니다.
조건부 타입
조건부 타입(Conditional Type)은 마치 자바스크립트의 3항 조건 연산자(?:)를 타입 수준에서 사용하는 것과 비슷합니다. 조건부 타입을 사용하면 타입에 따라 다른 타입을 선택할 수 있습니다. 문법은 다음과 같습니다:
T extends U ? X : Y
위 구조는 "만약 T가 U에 할당가능(assignable)하면 X 타입, 그렇지 않으면 Y 타입"으로 해석됩니다. 간단한 예를 통해 살펴보겠습니다:
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true (string은 string에 할당가능하므로 true 타입)
type B = IsString<number>; // false (number는 string에 할당되지 않으므로 false 타입)
IsString<T>이라는 조건부 타입은 T가 string 타입인지의 여부에 따라 true 또는 false 타입을 결정합니다. A는 string을 넣었으므로 true 타입이 되며, B는 number를 넣었으므로 false 타입이 됩니다. (여기서 true와 false는 리터럴 타입으로, 실제 값으로 쓰이기보다는 타입상에서의 판정 결과로 이해하면 됩니다.)
조건부 타입은 보통 제네릭과 함께 활용되어, 제네릭 타입 매개변수에 조건을 걸거나 변환하는 데 사용됩니다. 예를 들어, TypeScript 내장 유틸리티 중 하나인 NonNullable<T>는 T에서 null과 undefined를 제거하는 타입인데, 이를 조건부 타입으로 표현하면 다음과 같습니다:
type NonNullable<T> = T extends null | undefined ? never : T;
즉, T가 null이거나 undefined에 할당될 수 있는 타입이면 never(무값 타입)을 결과로, 그렇지 않으면 T 자체를 결과로 삼아 결국 null/undefined를 제거하는 효과를 냅니다.
또 다른 활용으로, 함수 타입에서 반환 타입만 추출하는 조건부 타입을 작성해볼 수 있습니다:
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
여기에는 새로운 키워드 infer가 등장하는데, infer R은 "함수 T의 반환 타입을 R이라고 추론"한다는 의미입니다. 그래서 만약 T가 함수 타입이면 R을 결과 타입으로 하고, 그렇지 않으면 never로 처리하는 것입니다. 이 ReturnType 유틸리티는 실제로 TypeScript 표준 라이브러리에 제공되며, 함수의 반환 타입을 편리하게 얻는 데 쓰입니다.
조건부 타입의 강력함은 분산 조건부 타입(distributive conditional type)이라는 특성에서 나오는데, T가 유니언 타입인 경우 조건부 타입을 적용하면 유니언 각 구성 요소마다 조건을 판정하여 결과를 다시 유니언으로 합칩니다. 예를 들어:
type ToArray<T> = T extends any ? T[] : never;
type StrOrNumArray = ToArray<string | number>;
// 위 결과는 string[] | number[] 가 됩니다.
string | number에 조건부 타입 T extends any ? T[] : never를 적용하면, 각 구성 요소별로 배열 타입으로 만들어 다시 유니언으로 합치는 분산 효과가 나타납니다. (만약 분산을 원치 않는다면 조건문 양측의 T를 튜플로 감싸는 트릭도 있습니다만, 여기서는 자세히 다루지 않습니다.)
요약하면, 조건부 타입은 타입 레벨에서 if-else 로직을 구현하는 강력한 도구입니다. 주로 제네릭 타입을 변환하거나 필터링하는 용도로 쓰이며, infer 키워드와 함께 쓰이면 복잡한 타입 추론도 가능하게 해줍니다.
맵드 타입
맵드 타입(Mapped Type)은 기존 타입의 프로퍼티들을 이용해 새로운 타입을 생성하는 문법입니다. 객체 타입의 모든 프로퍼티에 일괄적인 변형을 가하고자 할 때 유용합니다. 맵드 타입의 기본 형식은 다음과 같습니다:
type NewType<OldType> = {
[Key in keyof OldType]: ...;
};
위 문법에서 keyof OldType은 OldType의 모든 프로퍼티 이름들의 유니언 타입입니다. 예를 들어 OldType에 a와 b 두 프로퍼티가 있다면 keyof OldType은 "a" | "b" 입니다. 맵드 타입은 이 키(Key)들을 하나씩 Key에 대입하면서 매핑된 타입을 만들어냅니다.
간단한 예로, 모든 프로퍼티 값을 boolean으로 바꾸는 맵드 타입을 만들어 보겠습니다:
type Flags<T> = {
[P in keyof T]: boolean;
};
interface FeatureSettings {
darkMode: string;
newUserProfile: number;
}
type FeatureFlags = Flags<FeatureSettings>;
/* FeatureFlags 타입은 아래와 같습니다:
{
darkMode: boolean;
newUserProfile: boolean;
}
*/
Flags<T>는 제네릭 맵드 타입으로, 주어진 타입 T의 모든 프로퍼티 P에 대해 boolean 타입을 가지도록 새로운 객체 타입을 생성합니다. 위에서 FeatureSettings의 각 속성 타입(string, number)은 Flags를 거치면서 모두 boolean으로 변환되어 FeatureFlags 타입을 얻습니다. 이처럼 맵드 타입을 사용하면 반복적인 타입 변환 작업을 한 번의 정의로 처리할 수 있습니다.
맵드 타입은 또한 한번에 프로퍼티 수식자(modifier)를 변경하는 데도 사용할 수 있습니다. TypeScript는 readonly (읽기 전용)나 ? (선택적)와 같은 속성 수식자를 지원하는데, 맵드 타입 내에서 이를 + 또는 - 기호와 함께 사용하여 추가하거나 제거할 수 있습니다:
type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
type Optional<T> = {
[P in keyof T]?: T[P];
};
Mutable<T>은 T의 모든 프로퍼티에서 readonly를 제거하고, Optional<T>는 모든 프로퍼티를 선택적으로 만듭니다. 반대로 Readonly<T>나 Required<T>와 같이 모든 프로퍼티를 읽기 전용 또는 필수로 만드는 것도 이와 비슷하게 정의할 수 있습니다. 실제로 TypeScript 표준 라이브러리에는 Readonly<T>, Partial<T> 등의 유틸리티 타입이 내장되어 있어 이러한 패턴을 쉽게 사용할 수 있습니다.
좀 더 나아가서, 맵드 타입은 기존 타입에서 일부 프로퍼티를 제거하거나 변경하는 데도 응용할 수 있습니다. 예를 들어 T에서 K 프로퍼티만 선택하는 Pick<T, K>는 다음과 같이 정의됩니다:
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
이 Pick 타입은 T의 키들 중 K에 해당하는 속성만 골라 새로운 타입을 만듭니다. 반대로 Omit<T, K>은 K에 해당하는 속성을 제외하고 만드는 타입인데, 이는 Pick과 Exclude를 조합하여 구현할 수 있습니다:
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
Exclude<keyof T, K> 부분은 T의 모든 키에서 K 집합을 빼버리는 유니언을 얻고, 그걸 가지고 Pick을 하는 식입니다.
이렇듯 맵드 타입은 객체 타입 변환을 위한 강력한 도구이며, 조건부 타입과 결합하면 더욱 복잡한 변환도 가능해집니다. 하지만 실제로는 많은 경우 이미 표준으로 제공되는 유틸리티 타입(Partial, Required, Readonly, Pick, Omit 등)으로 커버할 수 있으므로, 필요한 용도가 있다면 직접 만들기 전에 표준 유틸리티에 존재하는지 확인하는 것이 좋습니다.
유틸리티 타입
TypeScript는 자주 쓰이는 패턴의 유틸리티 타입(Utility Types)을 기본 라이브러리로 제공하여, 개발자가 직접 고급 타입을 일일이 작성하지 않아도 되도록 돕습니다. 이들은 주로 앞서 언급한 맵드 타입이나 조건부 타입의 조합으로 구현되어 있으며, 아래와 같은 것들이 대표적입니다:
-
Partial<T>: 주어진 타입 T의 모든 프로퍼티를 선택적(?)으로 만들어 줍니다. 예를 들어Partial<User>는User의 일부 프로퍼티만 채워 넣을 수도 있는 형태를 표현합니다. 주로 함수에서 옵션 객체를 받을 때 활용합니다. -
Required<T>:Partial의 반대로, 모든 프로퍼티를 필수로 바꿉니다. (?제거) -
Readonly<T>: 모든 프로퍼티를 읽기 전용으로 만들어 변경할 수 없게 합니다. -
Record<K, V>: 키 집합 K와 값 타입 V를 받아, 해당 키들을 모두 가지고 값이 V인 객체 타입을 생성합니다. 예를 들어Record<"a"|"b", number>는{ a: number, b: number }타입과 같습니다. -
Pick<T, K>: 앞서 본 대로, T 타입에서 특정 프로퍼티 K들만 선택하여 새 객체 타입을 만듭니다. -
Omit<T, K>: T 타입에서 특정 프로퍼티 K들을 제거한 객체 타입을 만듭니다. -
Exclude<T, U>: 유니언 타입 T에서 U에 해당하는 타입을 제외합니다. (예:Exclude<"a"|"b"|"c", "a"|"c">는"b"로 결과가 나옴) -
Extract<T, U>: Exclude의 반대로, T에서 U에 공통되는 타입만 추려냅니다. -
NonNullable<T>: T 타입에서null과undefined를 제거합니다. -
ReturnType<F>: 함수 타입 F의 반환 타입을 추출합니다. -
InstanceType<C>: 클래스 타입 C를 받아, 그 클래스의 인스턴스 타입을 반환합니다.
그리고 ES5/ES6의 ThisParameterType, OmitThisParameter, Parameters, ConstructorParameters 등등도 있습니다. 이러한 유틸리티 타입들은 고급 타입 문법을 미리 활용해 만들어 놓은 것들로서, 적절히 활용하면 복잡한 타입 정의를 수월하게 해줍니다.
예를 들어, 다음은 Partial과 Pick을 활용한 간단한 예제입니다:
interface Person {
name: string;
age: number;
address: string;
}
function updatePerson(id: number, updates: Partial<Person>) {
// 업데이트 객체는 Person 속성 중 일부만 가지고 있어도 됨
// ...
}
const change: Pick<Person, "name" | "age"> = { name: "Alice", age: 30 };
updatePerson(1, change);
updatePerson 함수는 Person 일부 정보만 담은 객체를 받아 해당 사람의 정보를 수정한다고 가정했습니다. updates 매개변수에는 Partial<Person> 타입을 사용하여 name, age, address 중 어느 것만 보내도 되도록 했습니다. 그리고 change 객체는 Pick<Person, "name" | "age"> 타입을 이용해 name과 age만 가진 객체로 만들었습니다. 이런 조합으로 함수 호출 시 타입 안전성과 유연함을 모두 얻을 수 있습니다.
유틸리티 타입의 구현 자체를 깊이 알 필요는 없지만, 어떤 것이 있고 무엇을 하는지만 파악해도 많은 시간을 절약할 수 있습니다. 필요하다면 커스텀 유틸리티 타입을 만들어 쓸 수도 있지만, 앞서 언급했듯이 표준 라이브러리에 상당수가 포함되어 있으므로 문서를 참고하는 것이 좋습니다.
컴파일러 설정과 tsconfig.json
TypeScript 코드는 컴파일러(tsc)를 통해 JavaScript로 변환됩니다. 이때 어떤 JavaScript 버전으로 변환할지, 어떤 타입 검사를 엄격히 할지 등을 설정하기 위해 tsconfig.json 파일을 사용합니다. tsconfig.json은 프로젝트의 컴파일러 옵션과 파일 포함/제외 등을 지정하는 구성 파일입니다.
tsconfig.json 소개
TypeScript 프로젝트에서는 루트에 tsconfig.json 파일을 두고, 여기에 컴파일러가 알아야 할 설정들을 JSON 형식으로 기술합니다. tsc --init 명령을 한 번 실행하면 기본 옵션이 채워진 tsconfig.json을 생성할 수 있습니다. tsconfig.json이 존재하면 tsc 명령은 별도 인자 없이 프로젝트 전체를 컴파일하며, VSCode 등의 IDE도 이 설정을 읽어 타입 검사와 자동 완성에 활용합니다.
tsconfig.json의 주요 항목으로는 compilerOptions, include, exclude, files 등이 있습니다:
-
include: 컴파일에 포함할 파일 경로 패턴 (예:
["src/**/*"]). -
exclude: 컴파일에서 제외할 경로 (보통
node_modules등). -
files: 컴파일할 개별 파일 목록을 직접 나열 (include를 쓰는 경우 잘 안 씀).
여기서는 중요한 compilerOptions 내 설정들을 위주로 살펴보겠습니다.
주요 컴파일러 옵션
compilerOptions 안에는 TypeScript 컴파일러 동작을 제어하는 다양한 옵션이 있습니다. 몇 가지 자주 사용하는 옵션을 소개합니다:
-
target: 어떤 ECMAScript 버전으로 트랜스파일할지 결정합니다. 예:
"target": "ES5"로 설정하면 출력 JS가 ES5 문법으로 다운레벨링됩니다. 최신 문법을 그대로 사용할 경우 ES2015/ES2020 등으로 설정할 수 있습니다. -
module: 모듈 시스템을 지정합니다. Node.js 환경이면
"CommonJS", 브라우저 번들링이나 ESM 환경이면"ESNext"나"ES2015"등을 사용합니다. 이 설정에 따라import/export구문이 어떻게 변환될지가 결정됩니다. -
lib: 컴파일 타겟 환경에서 사용할 JS 표준 라이브러리를 지정합니다. 예:
"lib": ["ES2018", "DOM"]를 지정하면 ES2018 표준 API와 브라우저 DOM API를 사용할 수 있습니다. 만약"DOM"을 넣지 않으면 document, window 같은 브라우저 객체에 대한 타입을 인식하지 못합니다. -
outDir: 컴파일된 JavaScript 파일이 출력될 디렉토리 경로입니다. 보통
"dist"나"build"폴더로 지정합니다. -
rootDir: 소스 파일들의 루트 디렉토리를 지정하여, outDir 내에 동일한 구조로 파일들을 출력할 수 있게 합니다.
-
strict: 타입 검사에 대한 엄격 모드를 활성화합니다.
"strict": true로 설정하면 여러 엄격 옵션 (noImplicitAny 등)이 한꺼번에 켜져 최대한 엄밀한 타입 체크를 수행합니다. 가능하면 strict 모드를 사용하는 것이 권장됩니다. -
noImplicitAny: 암시적 any를 허용하지 않도록 합니다. 이 옵션이 켜지면 타입이 명시되지 않고 추론도 불가능한 경우 오류가 발생합니다. (strict에 포함됨)
-
strictNullChecks:
null과undefined에 대한 엄격한 체크를 수행합니다. 켜면null이 가능한 변수는.property접근이나 함수 호출 전에 반드시 존재 여부를 확인해야 합니다. (strict에 포함됨) -
strictFunctionTypes, strictPropertyInitialization 등: 함수 타입 비교나 클래스 프로퍼티 초기화 등에 대한 기타 엄격 검사 옵션들 (모두 strict에 포함).
-
esModuleInterop: CommonJS 모듈을 import 할 때
defaultimport 구문을 편하게 쓸 수 있게 하는 옵션입니다. 예를 들어 esModuleInterop이 false이면import fs from "fs"가 에러나지만, true이면 허용됩니다. 일반적으로 많은 프로젝트에서 호환성을 위해 true로 켜두는 것이 편리합니다. -
skipLibCheck: 외부 정의 파일(.d.ts) 검사 생략 여부입니다. true로 하면 node_modules 등의 .d.ts 타입 검사를 건너뛰어 컴파일 속도를 높이고, 때로는 일부 타입 정의 충돌 오류를 피하는 데도 도움됩니다.
-
sourceMap: 컴파일된 JS에 소스맵(.map 파일) 생성을 할지 결정합니다. true로 하면 TS 코드와 매핑된 소스맵이 생성되어 디버깅시 TS 원본과 연계할 수 있습니다.
-
declaration: true로 설정하면 .d.ts 선언 파일 생성을 함께 수행합니다. 라이브러리 작성 시 유용합니다.
-
incremental: 증분 컴파일을 활성화하여 이전 빌드 결과를 캐시하고, 변경된 부분만 재컴파일해서 컴파일 속도를 향상시킵니다.
위는 일부 핵심 옵션이며, 이 밖에도 JSX 처리 (jsx 옵션), 경로 별칭 (paths와 baseUrl 옵션), 실험적 기능(예: experimentalDecorators for decorators) 등 다양한 설정이 있습니다. 공식 문서의 TSConfig Reference에서 모든 옵션의 설명을 볼 수 있습니다.
tsconfig.json 예시:
{
"compilerOptions": {
"target": "ES2018",
"module": "CommonJS",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
위 설정은 src 폴더 아래의 모든 .ts 파일을 컴파일 대상으로 하고, 결과를 dist 폴더에 CommonJS 모듈 형식의 ES2018 수준 코드로 출력합니다. 엄격한 타입 체크를 활성화했고, CommonJS import 편의와 라이브러리 체크 생략을 켰습니다. 프로젝트 상황에 맞게 이 예시를 수정하여 사용할 수 있습니다.
모듈 시스템과 import/export
TypeScript는 ECMAScript 모듈 시스템(ES Modules)을 기본적으로 따르며, import / export 구문을 통해 코드를 모듈화합니다. TypeScript의 모듈 사용법은 최신 JavaScript (ES6+)와 거의 동일하지만, 타입 정보를 다룬다는 점에서 몇 가지 부가적인 사항이 있습니다.
import / export 기본 사용법
모듈은 각각 자체적인 파일 단위로 존재하며, export 키워드로 내보낸 변수, 함수, 클래스 등을 다른 파일에서 import하여 사용할 수 있습니다. 예를 들어 한 파일에 다음과 같이 작성했다면:
// utils.ts
export function greet(name: string): string {
return `Hello, ${name}`;
}
export const PI = 3.14;
다른 파일에서 이 모듈을 불러와 사용할 수 있습니다:
// main.ts
import { greet, PI } from "./utils";
console.log(greet("TypeScript")); // "Hello, TypeScript"
console.log("원주율은", PI);
import { ... } from ... 구문은 명시적으로 내보낸 항목들을 구조 분해 형태로 가져오는 방식입니다. 만약 한 모듈에서 기본 내보내기(default export)를 했다면, 중괄호 없이 임의의 이름으로 import할 수도 있습니다:
// logger.ts
export default function log(message: string) {
console.log("[LOG]", message);
}
// main.ts
import logMessage from "./logger";
logMessage("테스트 메시지");
기본 내보내기는 모듈당 하나만 존재할 수 있으며, 가져오는 쪽에서는 원하는 이름을 붙여서 사용할 수 있습니다 (logMessage는 임의로 지은 이름). 기본 export와 명명된 export를 함께 사용하거나, import * as 구문으로 모든 내보내기를 한 번에 객체로 가져오는 등 다양한 방법이 존재하지만 문법 자체는 JavaScript와 동일합니다.
모듈 해석과 호환성: TypeScript에서 import 구문은 컴파일된 JavaScript의 모듈 시스템으로 변환됩니다. tsconfig.json의 module 옵션에 따라 CommonJS (require 함수) 형태로 출력될 수도 있고, ESNext (import 유지) 형태로 출력될 수도 있습니다. 예컨대 module: "CommonJS"로 설정하면 위 import { greet } from "./utils"는 컴파일 결과 const utils_1 = require("./utils");로 바뀌고, greet 사용 부분은 utils_1.greet로 참조됩니다. 반면 module: "ES2020"이면 import/export가 그대로 남습니다. TypeScript 컴파일러는 모듈 해석 단계에서 .ts나 .tsx 확장자를 자동으로 처리하며, include/exclude 설정에 따라 어느 파일들이 모듈로 컴파일될지 결정합니다.
Node.js에서 ES 모듈을 사용할 때 .mjs 확장자나 package.json의 "type": "module" 설정이 필요할 수 있는데, TypeScript를 사용할 경우 module 설정과 Node 버전에 따라 이런 부분도 고려해야 합니다. 그러나 TS 자체는 소스 레벨에서 그냥 import/export를 사용하면 되고, 컴파일 결과를 Node가 이해하도록 설정하면 됩니다.
타입만 import/export하기
TypeScript에서는 타입 정보만을 가져오거나 내보내는 경우도 있습니다. 예를 들어 어떤 모듈에서 인터페이스 또는 타입 별칭을 정의하고 그것만 사용하고 싶을 때, 일반 import와 동일하게 하면 자바스크립트 출력에는 해당 import가 남지 않지만, 가독성을 위해 TypeScript 3.8+부터는 import type 구문을 사용할 수 있습니다:
// shapes.ts
export interface Circle { radius: number; }
export interface Square { sideLength: number; }
// area.ts
import type { Circle, Square } from "./shapes";
function area(shape: Circle | Square): number {
if ("radius" in shape) {
return Math.PI * shape.radius ** 2;
} else {
return shape.sideLength * shape.sideLength;
}
}
import type으로 가져온 것은 컴파일 시 타입 검사에만 사용되고, 출력된 JS에는 그 import 문이 제거됩니다. (일반 import도 사용되는 것이 타입 뿐이면 제거되지만, import type은 명시적으로 타입 전용 임을 나타냅니다.)
마찬가지로 export도 export type { ... } 형태로 타입만 내보낼 수 있습니다. 이는 모듈 간에 타입 정의를 공유할 때 유용하며, 사이클 의존성을 방지하는 등 몇 가지 편의가 있습니다.
네임스페이스와 기타 구문
과거 TypeScript에서는 내부 모듈 개념으로 네임스페이스(namespace)라는 구문이 있었습니다 (namespace X { ... }와 import X = require("...") 등). 그러나 ES Module 표준이 자리잡은 현재는 네임스페이스 대신 파일 모듈과 import/export를 사용하는 것이 일반적입니다. 네임스페이스는 주로 전역 스크립트에서의 충돌을 막거나, 아주 오래된 버전과의 호환을 위해 남아 있지만, 현대적인 TypeScript 프로젝트에서는 사용을 지양합니다. 만약 기존에 네임스페이스를 사용하던 코드를 마이그레이션 한다면, 해당 내용들을 모듈화하고 export/import로 교체하는 것이 좋습니다.
정리하면, TypeScript에서 모듈을 다룰 때는 ES6 스타일의 import/export를 사용하면 되며, 컴파일러 설정에 따라 Node.js나 브라우저 환경에 맞춰 적절히 변환됩니다. 또한 타입만을 주고받는 경우 import type 같은 구문을 사용하여 명확히 할 수 있습니다.
타입 정의 파일(.d.ts)
TypeScript의 강점 중 하나는 기존 JavaScript 라이브러리와의 호환성입니다. JS로 작성된 라이브러리를 TypeScript에서 사용하려면 해당 라이브러리의 타입 정의 파일(declaration file)이 필요합니다. 타입 정의 파일은 일반적으로 .d.ts 확장자를 가지며, 순수히 타입 정보만을 담고 있는 파일입니다.
타입 정의 파일이란?
.d.ts 파일은 컴파일 결과로 .js가 존재하거나 런타임에 실제 구현체가 있는 요소들에 대해, 그 타입 시그니처를 제공하는 역할을 합니다. 예를 들어 Node.js의 fs 모듈은 JavaScript로 구현되어 있지만, TypeScript로 fs를 import해서 사용할 때 fs.readFile 등이 어떤 매개변수를 받고 어떤 값을 반환하는지 알 수 있도록, 해당 정보가 담긴 타입 정의 파일 (node_modules/@types/node/fs.d.ts 등)이 존재합니다. 이렇게 선언(Declaration)만 있고 구현은 없는 파일을 통해 TypeScript 컴파일러는 해당 API를 타입 검사할 수 있게 됩니다.
DefinitelyTyped와 @types
대부분의 인기 있는 JavaScript 라이브러리들은 TypeScript용 .d.ts 파일을 함께 제공하거나, DefinitelyTyped라는 오픈 소스 저장소를 통해 배포하고 있습니다. DefinitelyTyped에 있는 라이브러리는 @types/라이브러리이름 형식으로 npm 패키지를 설치하여 타입 정의를 가져올 수 있습니다. 예를 들어 jQuery의 타입 정의를 쓰려면 npm install --save-dev @types/jquery를 설치하고, lodash를 쓰려면 npm install --save-dev @types/lodash를 설치하는 식입니다. 설치하면 node_modules/@types 경로 아래에 해당 .d.ts 파일들이 들어가며, tsconfig.json의 typeRoots나 types 옵션으로 별도로 제외하지 않는 한, 자동으로 인식되어 사용됩니다.
일부 최신 라이브러리는 자체적으로 TypeScript로 작성되어 있어서 .d.ts 파일이 따로 필요 없기도 합니다 (예: React, Angular 등의 주요 프레임워크는 TS로 작성되었거나 타입을 내장). 그러나 순수 JS로 작성된 라이브러리는 거의 항상 @types 패키지를 함께 설치하는 습관을 들여야 합니다. 만약 @types에도 없고 라이브러리도 타입을 제공하지 않는다면, 직접 타입 정의 파일을 작성해서 프로젝트에서 사용해야 합니다.
.d.ts 직접 작성하기
직접 타입 정의 파일을 작성해야 하는 경우, 크게 두 가지 상황이 있습니다:
-
전역 스크립트 또는 UMD 라이브러리: 라이브러리가 ES6 모듈 형식이 아니라
<script>로 로딩되는 전역 변수 제공 형태라면, .d.ts에declare global또는declare namespace등을 사용해 전역에 존재하는 값을 선언해야 합니다. -
ES 모듈 형태 라이브러리: ES6
import/export를 사용하는 모듈이라면, .d.ts 파일에서declare module "패키지이름"블록 내에 export 선언을 해주거나, .d.ts 자체를 그 모듈 이름으로 파일을 만들어주면 됩니다.
예를 들어, 간단한 전역 라이브러리라 가정하고 .d.ts를 작성한다면:
// globals.d.ts
declare global {
function greet(msg: string): void;
}
이렇게 하면 프로젝트 전역에 greet 함수가 있다고 가정하고 사용할 수 있습니다. (JSDoc 스타일로 // @types 주석을 달아도 비슷한 효과를 낼 수 있지만 .d.ts가 더 명시적입니다.)
ES 모듈 형태의 예를 들면, awesome-lib라는 NPM 패키지가 있고 타입이 없어서 우리가 정의한다고 하면:
// awesome-lib.d.ts
declare module "awesome-lib" {
export function doSomething(value: string): number;
export const version: string;
}
위처럼 declare module "moduleName" 구문으로 해당 모듈의 타입을 정의합니다. 이제 코드에서 import { doSomething } from "awesome-lib";을 사용하면 이 정의에 기반한 타입 검사가 이뤄집니다.
주의: 직접 정의한 .d.ts 파일이 효과를 발휘하려면, 해당 파일이 컴파일러의 인식 범위에 들어와야 합니다. 보통 tsconfig.json의 include에 **/*.d.ts를 넣거나, types 옵션을 통해 포함시킬 수 있습니다. 또는 index.d.ts처럼 패키지 루트에 두면 자동 인식됩니다.
또한, 선언 병합(declaration merging)을 활용하여 기존 정의를 확장할 수도 있습니다. 예를 들어 Express의 Request 객체에 커스텀 속성을 추가하고 싶다면:
// express-custom.d.ts
import "express"; // express 모듈의 타입들을 가져옴
declare global {
namespace Express {
interface Request {
currentUser?: User;
}
}
}
이런 식으로 namespace Express { interface Request { ... } }를 동일한 이름으로 다시 선언하면 원래 @types/express의 Request 정의에 속성이 합쳐집니다. 이러한 선언 병합은 라이브러리의 타입을 확장하거나 오버로딩할 때 활용됩니다.
d.ts 출력과 @types 제네레이터
TypeScript에서 compilerOptions.declaration: true로 설정하고 코드를 컴파일하면, .js 출력과 함께 .d.ts 파일도 생성됩니다. 이를 이용하면 자신의 라이브러리를 TypeScript로 작성한 후 .d.ts를 배포하여 JavaScript 사용자에게 타입 정의를 제공할 수 있습니다.
만약 프로젝트에 JavaScript (.js)와 TypeScript가 혼용되어 있다면, JSDoc을 활용해 .js 파일에 타입 정보를 넣고, allowJs 및 declaration 옵션으로 .d.ts를 생성하는 방법도 있습니다. 점진적인 마이그레이션에 도움이 될 수 있는 팁입니다.
정리하면, .d.ts 파일은 TypeScript와 JavaScript 사이의 다리 역할을 하는 중요한 요소입니다. 이미 많은 정의가 DefinitelyTyped를 통해 제공되므로 가급적 기존 것을 활용하고, 부득이한 경우 직접 선언 파일을 작성하여 사용하면 됩니다.
React와 TypeScript 통합
React 애플리케이션에서 TypeScript를 사용하면 컴포넌트의 props, state, 그리고 훅(Hook) 등을 타입 안전하게 다룰 수 있어 개발자 경험이 크게 향상됩니다. 여기서는 React와 TypeScript를 함께 사용하는 방법과 주요 예제를 살펴보겠습니다.
React 프로젝트에 TypeScript 적용
새로운 React 프로젝트를 만든다면, Create React App(CRA)이나 Vite 등 툴에서 TypeScript 템플릿을 제공하기 때문에 손쉽게 시작할 수 있습니다. 예를 들어 CRA의 경우 npx create-react-app my-app --template typescript 명령으로 TS 설정이 포함된 프로젝트를 생성할 수 있고, Vite의 경우 npm init vite@latest my-app -- --template react-ts와 같은 방법이 있습니다. 이렇게 시작하면 .tsx 확장자를 가지는 파일들을 작성할 수 있고, React 관련 @types (예: @types/react, @types/react-dom)도 기본 포함되어 나옵니다.
기존 React(JavaScript) 프로젝트에 TypeScript를 도입하려면, 우선 typescript 패키지와 React의 타입 패키지들을 개발 의존성으로 설치하고 (npm install --save-dev typescript @types/react @types/react-dom 등), tsconfig.json을 설정해야 합니다. 그리고 .js나 .jsx 파일들을 .ts 또는 .tsx로 리네임하고, 발생하는 타입 오류들을 하나씩 해결해나가는 과정이 필요합니다. 특히 JSX를 포함한 파일은 .tsx 확장자를 사용해야 함에 주의해야 합니다. 빌드 도구(Webpack 등) 설정도 TS를 처리할 수 있게 ts-loader나 Babel preset을 추가하고, .tsx 확장자를 인식하도록 조정해야 합니다.
컴포넌트 Props와 State 타입 정의
React 컴포넌트의 props에 TypeScript를 적용하면, 컴포넌트를 사용하는 곳에서 잘못된 props를 넣었을 때 컴파일 타임에 에러를 발견할 수 있습니다. 함수형 컴포넌트 (Functional Component)를 예로 들어, props의 형태를 인터페이스로 정의하고 컴포넌트의 제네릭 혹은 매개변수로 활용할 수 있습니다:
import React from 'react';
interface HelloProps {
name: string;
enthusiasmLevel?: number;
}
const Hello: React.FC<HelloProps> = ({ name, enthusiasmLevel = 1 }) => {
if (enthusiasmLevel <= 0) {
throw new Error("enthusiasmLevel must be positive");
}
return <h1>Hello, {name + '!'.repeat(enthusiasmLevel)}</h1>;
};
export default Hello;
위 코드에서 HelloProps 인터페이스가 컴포넌트 props의 타입을 정의합니다. enthusiasmLevel은 선택적이어서 전달되지 않을 수도 있으며, 기본값으로 1을 지정했습니다. React.FC<HelloProps>를 사용하면 함수형 컴포넌트에 제네릭으로 props 타입을 넘긴 것이고, 따라서 내부에서 props 구조분해할 때 각 prop의 타입이 추론됩니다. (참고로 React.FC를 쓰지 않고 function Hello(props: HelloProps) { ... }로 작성해도 무방합니다.)
클래스형 컴포넌트의 경우, React.Component<P, S> 제네릭에 Props와 State 타입을 전달합니다:
import React from 'react';
interface CounterProps {
initialCount?: number;
}
interface CounterState {
count: number;
}
class Counter extends React.Component<CounterProps, CounterState> {
state: CounterState = {
count: this.props.initialCount ?? 0
};
render() {
return (
<div>
<p>Count: {this.state.count}</p>
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
Increment
</button>
</div>
);
}
}
CounterProps와 CounterState를 정의하고 React.Component<CounterProps, CounterState>로 지정했습니다. 이렇게 하면 this.props와 this.state가 각각 기대된 타입을 가지며, 잘못된 사용을 할 경우 컴파일러가 잡아줍니다 (예: 존재하지 않는 prop 접근 등).
물론 함수형 컴포넌트+Hook 스타일이 주류이므로 클래스 컴포넌트 예시는 참고만 하면 됩니다.
Hooks에 Type 적용
React Hook을 사용할 때 TypeScript는 상당 부분 타입 추론을 통해 편의를 제공합니다. 예를 들어 useState는 초기값을 보고 상태 타입을 유추합니다:
const [count, setCount] = React.useState(0);
// count: number로 추론, setCount: React.Dispatch<React.SetStateAction<number>>
위처럼 0을 초기값으로 넣으면 count는 number로 추론되므로 setCount("hi")처럼 숫자가 아닌 값을 넣으면 오류가 발생합니다. 초기값이 null이거나 undefined일 경우엔 제네릭으로 타입을 명시해 줄 수도 있습니다:
const [user, setUser] = React.useState<User | null>(null);
이렇게 하면 user의 초기값은 null이지만 이후 User 객체 또는 null을 가질 수 있는 상태로 타입이 지정됩니다.
useReducer도 마찬가지로, 보통 reducer 함수와 초기 state를 인자로 받으면 타입 추론이 동작합니다. 액션 타입을 미리 정의하고, reducer 함수의 인자에 타입을 붙이면 dispatch의 인자도 자동으로 타입 체크됩니다.
type CounterAction = { type: "increment"; amount: number } | { type: "reset" };
interface CounterState { count: number; }
function counterReducer(state: CounterState, action: CounterAction): CounterState {
switch(action.type) {
case "increment":
return { count: state.count + action.amount };
case "reset":
return { count: 0 };
}
}
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
dispatch({ type: "increment", amount: 5 }); // OK
dispatch({ type: "decrement", amount: 3 }); // 오류: 'decrement'는 CounterAction에 없음
위에서 CounterAction을 유니언 타입으로 정의하고, counterReducer 함수에 적용했습니다. useReducer에 이 함수를 넘기면 자동으로 dispatch는 CounterAction만 받는 함수로 타입이 정해지므로, 잘못된 액션을 전달하면 컴파일 오류가 발생합니다.
useRef 훅의 경우, useRef<타입>(초기값) 형태로 제네릭을 명시하여 current 값의 타입을 설정해줄 수 있습니다. 특히 DOM 요소를 다루는 경우 useRef<HTMLInputElement>(null!) 이런 식으로 널이 초기일 때 Non-null assertion (!)을 붙여 사용하기도 합니다.
JSX와 타입
TypeScript는 JSX를 지원하며, 파일 확장자를 .tsx로 하면 JSX 문법을 파싱합니다. React의 JSX에서 태그는 JSX.Element 타입으로 간주됩니다. 일반적으로 JSX 작성 시 타입을 직접 언급할 일은 없지만, 컴포넌트의 props나 children 타입과 연관되어 있습니다. 예를 들어 children을 가지는 컴포넌트를 타입으로 표현할 때 React.PropsWithChildren<P>를 사용하거나, React.FC를 쓰면 기본적으로 children 타입이 포함됩니다.
JSX 내부에서 DOM 요소를 타입으로 인식할 때, JSX pragma 설정이나 react-jsx 등의 tsconfig 설정에 따라 조금 다를 수 있는데, 표준 CRA 환경이라면 자동으로 React 17+의 new JSX transform에 맞춰 동작합니다.
React + TypeScript 예제
아래는 간단한 React + TS 예제입니다:
import React, { useState } from 'react';
interface ToggleProps {
initialOn?: boolean;
onChange?: (on: boolean) => void;
}
const Toggle: React.FC<ToggleProps> = ({ initialOn = false, onChange }) => {
const [isOn, setIsOn] = useState(initialOn);
const toggle = () => {
const newState = !isOn;
setIsOn(newState);
onChange?.(newState);
};
return <button onClick={toggle}>{isOn ? "ON" : "OFF"}</button>;
};
// 사용 예시
<Toggle initialOn={true} onChange={state => console.log("토글 상태:", state)} />;
ToggleProps에서 onChange 콜백은 불리언 상태를 받아 리턴이 없는 함수로 정의했고, onChange?.(newState)처럼 옵셔널 체이닝으로 안전히 호출했습니다. 이 컴포넌트를 사용하는 쪽에서는 initialOn을 boolean으로, onChange에 맞는 함수로 넣어야 하며, 그렇지 않으면 컴파일 에러가 납니다.
React와 TypeScript를 같이 쓰면 이처럼 컴포넌트 계약을 명확히 정의할 수 있어 큰 규모 앱에서 특히 오류를 줄이는 데 도움이 됩니다. 또한 IDE의 자동 완성으로 어떤 props가 필요한지 쉽게 알 수 있고, refactoring 시에도 컴파일러가 영향을 받는 코드를 추적해줍니다.
Node.js와 TypeScript 통합
백엔드 Node.js 환경에서도 TypeScript를 활용하면 JavaScript 런타임에서 흔히 발생하는 타입 오류를 방지하고, 더 나은 개발자 경험을 얻을 수 있습니다. 이번 섹션에서는 Node.js + Express 서버를 TypeScript로 작성하는 방법을 중심으로 살펴봅니다.
Node.js 프로젝트 설정
Node.js 프로젝트에 TypeScript를 적용하려면 우선 TypeScript 컴파일러와 Node 타입 정의를 설치해야 합니다:
npm install --save-dev typescript ts-node @types/node
-
typescript: TS 컴파일러 -
ts-node: TypeScript 실행기 (컴파일 없이 바로 실행, 개발 편의용) -
@types/node: Node.js 표준 라이브러리 (fs, http 등) 타입 정의
그 다음 프로젝트 루트에 tsconfig.json을 생성 (tsc --init)하고, Node 환경에 맞게 compilerOptions를 조정합니다. Node 14 이상에서는 ES2020 기능을 많이 지원하므로:
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"outDir": "./dist",
"esModuleInterop": true,
"strict": true
},
"include": ["src"]
}
이런 설정을 예로 들 수 있습니다. module은 CommonJS로 (Node 기본), esModuleInterop을 true로 하여 CommonJS 모듈도 import 구문으로 부드럽게 import할 수 있게 했습니다. (예: import express from "express";가 가능해짐) strict 모드도 켜서 엄격한 타입 체크를 하도록 했습니다.
소스 코드는 src 폴더에 .ts로 작성하고, tsc를 돌리면 dist에 .js가 생깁니다. 혹은 ts-node를 이용하면 컴파일-실행을 한꺼번에 할 수 있어 개발 중 빠르게 테스트하기 좋습니다 (예: npx ts-node src/index.ts).
Express 서버 예제 (타입 안전한 API 서버)
Express를 TypeScript로 사용할 때는 Express 자체의 타입 정의(@types/express)가 필요합니다:
npm install --save express
npm install --save-dev @types/express
Express 요청(Request)과 응답(Response) 객체에는 제네릭 타입 매개변수가 정의되어 있어, 원하는 경우 요청의 Params, Query, Body, 응답 Body 등의 타입을 구체적으로 지정할 수 있습니다. 간단한 사용법을 예제로 보여드리겠습니다:
import express, { Request, Response } from 'express';
const app = express();
app.use(express.json());
interface User {
name: string;
age: number;
}
// GET /hello/:name 엔드포인트 (경로 파라미터 사용)
app.get('/hello/:name', (req: Request<{ name: string }>, res: Response) => {
const { name } = req.params; // name is string
res.send(`Hello, ${name}`);
});
// POST /users 엔드포인트 (JSON 바디 사용)
app.post('/users', (req: Request<{}, {}, User>, res: Response<{ success: boolean }>) => {
const newUser: User = req.body;
// ... (여기서 newUser.name, newUser.age 사용 가능, 타입 체크됨)
res.status(201).json({ success: true });
});
app.listen(3000, () => {
console.log("Server is running on port 3000");
});
위 코드에서:
-
app.get라우트는 경로 매개변수:name이 있으므로Request<{ name: string }>형태로 첫 번째 제네릭을 채워 주었습니다. 이제req.params.name은 string 타입으로 인식됩니다. -
app.post라우트는 Request의 세 번째 제네릭 매개변수 (Request Body 타입)에User인터페이스를 넣었습니다. 따라서req.body는 User 타입으로 취급되어name과age프로퍼티를 사용할 수 있고, 잘못된 속성이 들어오면 컴파일러가 경고합니다. 또한 Response의 제네릭으로{ success: boolean }을 넣어res.json이 그 형태의 데이터를 보내는 것으로 타입을 알 수 있게 했습니다.
이처럼 Express의 타입 정의를 활용하면 경로 파라미터, 쿼리 스트링, 요청 바디, 응답 데이터 모두에 타입을 부여할 수 있습니다. IDE 상에서 req.를 입력하면 req.body, req.params 등의 구조가 어떤 속성을 가지는지 자동 완성으로 확인할 수 있고, 한 번 정의해두면 서버와 클라이언트 간 인터페이스를 맞출 때 타입 재사용도 가능합니다 (예: User 인터페이스를 클라이언트 코드에서도 써서 같은 구조를 공유).
Node.js 내장 모듈(fs, path 등)이나 다른 서버 라이브러리들도 대부분 @types 패키지가 있거나 자체 타입이 내장되어 있으므로, 문서 참고하며 사용하면 됩니다. 예를 들어, Node의 fs.readFileSync는 타입 정의에 따르면 fs.readFileSync(path: string | Buffer | URL, options?: { encoding?: null; flag?: string; }): Buffer 같은 식으로 정의돼 있어, 경로 인자 타입이나 반환 Buffer가 명확히 드러납니다.
타입 안전한 서버 구성의 이점
TypeScript로 Node.js 서버를 작성하면,
-
API의 입력과 출력 스키마를 명시적으로 타입으로 정의하여, 구현이 그 계약을 따르는지 검사할 수 있습니다.
-
DB 모델이나 비즈니스 로직 객체에도 타입을 적용해, 존재하지 않는 필드에 접근하거나 타입을 잘못 사용하는 실수를 줄입니다.
-
리팩토링 시 컴파일 타임 오류를 통해 수정해야 할 부분을 알아낼 수 있어 대규모 코드 수정이 수월해집니다.
예를 들어, 특정 라우트가 반환하는 JSON 구조를 변경했다면 해당 Response<NewType> 정의를 업데이트하고, 그 타입을 사용하던 클라이언트 코드(만약 TS로 공유된다면)도 자동으로 영향을 받아 컴파일 오류를 통해 수정 지점을 알려줄 것입니다. 이렇듯 TS는 백엔드-프론트엔드 간 계약(interface)을 코드 수준에서 공유하는 것도 가능케 합니다 (예: 공통 패키지로 types만 따로 분리).
기타 Node와 TS 사용 팁
-
ts-node: Node.js에서 TS 파일을 바로 실행할 때 유용합니다. 개발 중에
nodemon과 함께 쓰면, 파일 변경 시 자동 재실행도 TS 컴파일을 거쳐 처리할 수 있습니다 (nodemon --exec ts-node src/index.ts). -
디버깅: VSCode 등에서 TypeScript로 작성된 Node 코드를 디버깅할 때는,
sourceMap: true로 설정하고 .map 파일을 생성해두면 브레이크포인트를 TS 소스에 걸 수 있습니다. ts-node를 사용할 경우--inspect플래그와 함께 VSCode attach를 사용하기도 합니다. -
빌드 스크립트: 프로덕션 빌드를 위해서는 TS를 JS로 컴파일해야 하므로 npm script에
"build": "tsc"를 추가하고,"start": "node dist/index.js"등으로 실행하는 식으로 구성합니다. 개발 시에는"dev": "ts-node src/index.ts"처럼 ts-node를 쓸 수도 있습니다.
Node.js와 TypeScript 조합은 NestJS 같은 프레임워크를 통해서도 많이 쓰이고 있지만, 경량 Express나 KOA 같은 경우도 충분히 직접 설정하여 쓸 수 있습니다. 처음 설정이 약간 귀찮을 수 있지만, 일단 환경이 구축되고 나면 타입 지원 덕분에 개발 속도가 오히려 빨라지고 안정성도 높아지는 것을 느낄 수 있을 것입니다.
프로젝트에 TypeScript 도입하기: 전략과 팁
기존 프로젝트나 새 프로젝트에 TypeScript를 도입할 때 고려할 사항과 유용한 팁들을 정리해봅니다. 올바른 전략을 따르면 점진적으로 또는 초기부터 TS를 활용하면서 발생할 수 있는 혼란을 줄일 수 있습니다.
-
점진적 도입: 이미 JavaScript로 작성된 대규모 코드베이스에 TS를 적용하려면 한꺼번에 변환하기 어렵습니다. 이 경우 점진적 마이그레이션 전략을 취합니다.
tsconfig.json에서allowJs: true옵션을 켜서.js파일을 TS 컴파일 대상에 포함시키고, 필요한 경우checkJs: true로 설정하거나 파일 상단에// @ts-check주석을 추가하여 기존 JS에 대해서도 타입 검사를 부분적으로 적용해 볼 수 있습니다. 그런 다음, 하나씩 파일을.ts또는.tsx로 변환하면서 타입 에러를 해결해 나갑니다. 이 과정에서any를 일시적으로 사용하거나,// @ts-ignore로 특정 라인의 오류를 무시하는 방법도 쓸 수 있지만, 최종 목표는 그런 구멍들을 제거하는 것입니다. -
엄격 모드 고려: 새로 시작하는 프로젝트라면
strict: true를 권장합니다. 다만, 기존 프로젝트에 도입할 때는 처음부터 strict 모드로 하면 오류가 폭발적으로 나타날 수 있으므로, 우선 strict를 끄고 하나씩 옵션을 켜보는 방법도 있습니다. 예를 들어noImplicitAny,strictNullChecks등을 하나씩 적용해보고, 코드베이스에서 문제가 되는 부분을 수정하는 식입니다. strictNullChecks는null처리 누락 버그를 잡는 데 유용하지만, 기존 코드에 null이 남용되어 있으면 수정량이 많을 수 있으니 주의합니다. -
타입 정의 활용: 외부 라이브러리의 타입 정의를 반드시 설치하고 활용하세요. @types에 없는 라이브러리를 쓰고 있다면, 가능한 대체 라이브러리를 찾거나 직접 타입을 만들어보는 것도 고려해야 합니다. TS 도입 시 장애물 중 하나가 사용중인 패키지에 타입 지원이 없는 경우인데, 이때는
declare module '패키지';형태로 임시 선언하여 사용 가능하게 한 후, 나중에 공식 타입 정의가 나오면 교체하는 식으로 할 수 있습니다. -
기존 JS JSDoc 활용: 만약 기존 JS 함수들에 JSDoc 주석으로 타입 정보를 붙여놓았다면, TS는
allowJs와checkJs옵션을 통해 그것을 활용할 수 있습니다. JSDoc 태그@param,@returns,@typedef등을 사용하면.js파일에서도 TS가 타입을 추론하고 오류를 표기해줍니다. 이 방식을 사용하면 .js 파일도 천천히 타입 체킹을 적용하면서, 진짜 TS 파일로 변환하는 시점을 조율할 수 있습니다. -
빌드 설정 변경: TS 도입 시 빌드 파이프라인도 조정이 필요합니다. Webpack을 쓰고 있었다면
ts-loader또는 Babel을 통한 TS 처리가 필요하고, lint 도구(ESLint)를 사용 중이면@typescript-eslint/parser로 변경하여 룰을 업데이트해야 합니다. Jest 같은 테스트 러너도ts-jest또는 Babel 설정이 필요합니다. 이런 도구 설정을 한꺼번에 바꾸는 것이 부담된다면, CRA + react-scripts-ts 같은 초기 설정을 활용하거나, Vite, Next.js처럼 TS 지원이 내장된 프레임워크를 사용하는 것도 방법입니다. -
코드 스타일 및 규칙: TS로 전환하면서 권장되는 스타일을 팀 차원에서 합의하면 좋습니다. 예를 들어, 언제 인터페이스를 쓰고 언제 타입 별칭을 쓸지, any를 절대 금지할지 아니면 예외를 둘지, 함수 반환 타입은 항상 명시할지 추론에 맡길지 등의 규칙입니다. 또한 린터를 통해
no-explicit-any같은 규칙을 켜서 any 남용을 막을 수 있습니다. Prettier나 ESLint의 formatting도 TS 전용으로 맞춰서 일관성있게 유지합니다. -
교육 및 코드리뷰: 팀원 중 TS가 익숙하지 않은 사람이 있다면, 작은 부분부터 같이 페어 프로그래밍으로 적용해 보고, 코드 리뷰를 통해 피드백을 주고받는 것이 중요합니다. TS 자체보다는 프로젝트의 타입 패턴 (예: API 타입은 이런 식으로 정의한다, 유틸리티 타입 만들어서 쓴다 등)을 공유하세요.
-
점진적 개선: 처음 TS 도입시에는 완벽한 타입 모델을 추구하기 어렵습니다. 일단 any로 뚫어 놓고 넘어가야 생산성이 확보되는 부분도 있습니다. 이런 곳은
// TODO: refine types처럼 주석을 남겨두고, 나중에 시간을 들여 개선하면 됩니다. 모든 코드를 한 번에 고치는 것보다, 일단 TS로 전환하여 돌아가게 만든 후 엄격성을 높이는 방향이 현실적입니다.
요약하면, TypeScript 도입은 단지 문법 교체가 아니라 개발 방식의 전환입니다. 타입 설계에 시간을 쓰지만 그만큼 버그를 줄이고 리팩토링을 쉽게 하는 식으로, 초기 투자와 장기 효율의 균형을 고려해야 합니다. 잘 계획하고 진행하면, 팀의 코드 품질과 개발자 역량 모두 향상되는 긍정적인 결과를 얻을 수 있습니다.
자주 발생하는 오류와 디버깅
TypeScript를 사용하다 보면 만나게 되는 흔한 오류 메시지와 해결 방법을 알아두면 개발이 훨씬 수월해집니다. 여기서는 입문자들이 자주 접하는 몇 가지 타입 오류와 처리 요령, 그리고 TS 코드 디버깅 팁을 소개합니다.
-
"Type 'X' is not assignable to type 'Y'": 가장 흔히 보는 오류 중 하나로, 변수나 함수 반환값 등이 기대된 타입 Y와 맞지 않을 때 발생합니다. 예를 들어
let num: number = "hello";를 하면"hello"는 number에 할당될 수 없다는 오류가 나옵니다. 이 오류가 나면 우선 X와 Y의 타입이 왜 다른지 살펴보고, 실제 코드 로직상 무엇이 맞는 타입인지 결정해야 합니다. 필요하다면 타입 변환이나 단언 (as)을 고려할 수 있지만, 근본적으론 잘못된 대입이나 반환을 고치는 게 맞습니다. -
"Property 'foo' does not exist on type 'Bar'": 객체
Bar에foo라는 속성이 없는데 접근하려 하면 생깁니다. 예를 들어 어떤 유니언 타입에서 특정 케이스에만 존재하는 속성을 아무 검사 없이 쓰려고 하면 이런 오류가 뜹니다. 해결하려면 해당 속성이 존재하는지 타입 가드를 적용해야 합니다. 예를 들어if('foo' in obj) { obj.foo ... }같은 검사를 하면, 그 블록 안에서는 obj를 속성이 있는 타입으로 간주합니다. 혹은 해당 속성이 확실히 있는 타입으로 변환이 필요하다면obj as HasFoo식으로 단언할 수도 있지만, 가능한 한 안전한 방법(타입 가드나 타입 좁히기)을 사용하는 것이 좋습니다. -
"Object is possibly 'null' or 'undefined'":
strictNullChecks옵션이 활성화된 경우 자주 보는 오류입니다. 변수나 속성이null또는undefined가 될 수 있는 상황에서, 그냥 사용하려 하면 컴파일러가 경고합니다. 해결법은 _null 체크_를 하는 것입니다:if (obj != null) { // 여기선 obj가 null/undefined 아님 obj.someMethod(); }또는 확신이 있다면 non-null assertion operator (
!)를 붙여obj!.someMethod()로 컴파일러 경고를 무시할 수 있습니다. 하지만!는 런타임 null을 방지하지 못하므로, 논리적으로 null이 아닐 경우에만 써야 합니다. -
"Argument of type 'X' is not assignable to parameter of type 'Y'": 함수 호출 시 인자 타입이 맞지 않을 때 발생합니다. 해당 함수 정의를 확인해서 요구되는 타입에 맞게 인자를 변환하거나, 함수 오버로드를 잘못 사용하고 있는 건 아닌지 살펴봐야 합니다. 가령
function printName(name: string) {...}인데printName(42)라고 호출하면 이런 에러를 봅니다. 간단히 타입에 맞게 수정하면 됩니다. -
"Type 'never' is not assignable to ..." 혹은 반환이 never:
never타입은 "절대 발생하지 않는 값"을 의미합니다. 주로 모든 경우를 처리하고도 남는 케이스가 없을 때 사용되는데, 예를 들어 switch문에서 모든 유니언 멤버를 처리했는데도 default에 도달하면 그 경우 타입이 never인 상황 등을 말합니다. 만약 어떤 변수 타입이 의도치 않게 never로 추론되었다면, 컴파일러가 "이 코드는 도달하지 않음"으로 판단한 것이므로, 이전 연산에서 로직이 잘못됐을 가능성이 큽니다. 이를 디버깅하려면 해당 변수나 표현식이 왜 never인지 추적해야 합니다. -
"No overload matches this call": 함수나 메서드가 오버로드 시그니처를 여러 개 가지고 있는데, 제공한 인자가 그 어느 시그니처와도 맞지 않을 때 나는 오류입니다. 이 경우 함수 정의를 확인해 어떤 형태로 호출할 수 있는지 보고, 인자 타입이나 개수를 조정해야 합니다. 예를 들어 DOM API
document.querySelector의 오버로드 중에서 잘못된 CSS 선택자를 주면 타입상 매치가 안 될 수 있는데, 보통은 오타 등의 문제입니다. -
"Cannot find module 'X' or its corresponding type declarations": import 문에서 외부 모듈을 가져올 때 TS가 그 모듈의 타입 정보를 찾지 못하면 나타나는 오류입니다. 이는 해당 라이브러리의
.d.ts가 없을 때 발생합니다. 해결 방법은 @types 패키지 설치입니다.npm install --save-dev @types/X로 타입을 설치하거나, 만약 존재하지 않는다면 임시로declare module 'X';를 프로젝트에 선언하여 any로 취급하게 할 수 있습니다. -
"Type instantiation is excessively deep and possibly infinite": 매우 복잡한 제네릭 타입 변환을 할 때 가끔 볼 수 있는 오류입니다. TS 컴파일러가 타입 연산을 너무 깊게 들어가서 난 해석 불가 상황입니다. 이런 경우는 일반 입문자에겐 드물지만, utility type을 과도하게 중첩 사용했거나, 재귀적 타입 정의가 잘못되었을 때 발생합니다. 보통은 타입 설계를 단순화하거나,
as any를 중간에 써서 컴파일러 추론을 끊어주는 방법 등으로 해결합니다. -
기타 컴파일 설정 오류: 예를 들면 "TS5058: The specified path does not exist"처럼 tsconfig의 include 경로가 잘못됐다거나, "Cannot compile external modules unless the '--module' flag is provided"처럼 설정 문제들이 있습니다. 이런 경우는 메시지 내용대로 tsconfig.json을 확인해서 값을 바꿔주면 됩니다.
디버깅 팁
TypeScript는 컴파일 타임 오류를 주로 다루지만, 실제 애플리케이션이 돌아갈 때는 여전히 JavaScript입니다. 런타임 오류를 디버깅하려면 일반 JS 디버깅 기법과 함께, TS 소스 맵을 활용하는 것이 좋습니다.
-
소스맵 활성화:
compilerOptions.sourceMap을 true로 설정하면 .map 파일이 생성되고, 브라우저나 Node.js 디버거가 원본 TS와 맵핑하여 디버깅을 도와줍니다. 예를 들어 크롬 개발자 도구나 VSCode 디버거에서 중단점을 TS 파일에 설정해도 실제 실행은 .js에서 이뤄지지만, 소스맵 덕분에 TS에서 중단된 것처럼 보입니다. -
stack trace 해석: 런타임 에러가 발생했을 때 스택 트레이스(콜스택)에 .js 파일과 줄이 나타날 수 있습니다. 소스맵이 적용된 환경이라면 TS로 변환해서 보여주지만, 그렇지 않다면 .js 위치를 TS로 역추적해야 할 수도 있습니다. 이럴 때는 .map 파일을 사용하거나, TS컴파일된 코드를 약간 살펴 이해해야 할 수도 있습니다.
-
ts-node debugging: ts-node로 직접 실행할 때 디버깅을 하려면,
node --inspect -r ts-node/register를 사용하는 방안 등이 있습니다. VSCode에서 Launch 설정에"runtimeArgs": ["-r", "ts-node/register"]등을 주어 TS 실행을 hooking하는 방법이 문서에 나와 있으니 참고하면 좋습니다. -
IDE 도움: VSCode를 비롯한 IDE들은 TypeScript 오류 메시지를 보기 쉽게 해석해주거나, Quick Fix 기능으로 자동 수정 제안을 해주기도 합니다. (예: "내보낸 함수에 반환 타입 안 썼네요. 추가할까요?" 같은) 이러한 도움을 적극 활용하세요.
-
strict 모드 디버깅: 엄격한 타입 체크로 인한 오류는 처음엔 귀찮게 느껴질 수 있지만, 사실상 런타임에 터질 버그를 미리 알려주는 것이므로, 그 의미를 이해하는 것이 중요합니다. 예컨대 "possibly 'undefined'" 오류가 나면 "아, 이 변수 혹시 undefined일 수 있구나, 내 코드 로직에서 그럴 가능성이 있네" 하고 깔끔히 처리해두면, 나중에 실제 장애를 예방하는 것입니다.
TypeScript 에러 메시지는 때로 길고 복잡하지만, 핵심 부분(앞쪽 줄)에 중요한 내용이 있습니다. 익숙해지면 메시지만 보고도 문제를 빠르게 파악할 수 있게 되니, 겁먹지 말고 찬찬히 읽어보는 습관을 가지면 좋겠습니다.
유용한 도구 및 라이브러리
TypeScript 개발을 도와주는 다양한 도구와 라이브러리가 존재합니다. 몇 가지 유용한 것들을 소개합니다:
-
ts-node: 앞서 언급한 대로, TS 컴파일 없이 Node.js에서 TS를 바로 실행하게 해주는 도구입니다. 실험용 스크립트 돌리거나, 간단한 서버를 빠르게 띄우는 용도로 쓸 수 있습니다. (개발환경 한정, 배포 시에는 일반적으로 TS를 JS로 빌드합니다.)
-
Visual Studio Code: TypeScript 지원을 가장 잘 해주는 IDE 중 하나입니다. MS에서 개발한 만큼 TS 언어 서비스와 통합되어 자동 완성, 리팩토링, 오류 표시 등이 뛰어납니다. 만약 다른 편집기를 쓰고 있다면, TS만큼은 VSCode를 고려해보는 것도 좋습니다.
-
ESLint + @typescript-eslint: 코드 린팅 도구인 ESLint를 TypeScript에 적용하려면,
@typescript-eslint/parser와 관련 플러그인이 필요합니다. 설정이 다소 복잡하지만, 적절히 규칙을 정하면 일관된 코딩 스타일 유지와 버그 예방에 도움이 됩니다 (예: unused 변수 체크, any 금지 등). -
Prettier: 코드 포매터로, TypeScript도 지원합니다. ESLint와 함께 쓰거나 단독으로 써서, 사람이 신경쓰지 않아도 코드를 보기 좋게 정렬해줍니다.
-
Jest + ts-jest: Jest 테스트 프레임워크를 TS로 사용하려면
ts-jest를 프리셋으로 설정하면 편합니다. 혹은 Babel을 사용 중이면 Babel로 TS 트랜스파일링하고 jest에서 받아 실행할 수도 있습니다. -
Type-fest: Sindre Sorhus 등이 만든 TypeScript 타입 유틸리티 모음 라이브러리입니다. TS 자체 유틸리티 외에도 유용한 고급 타입들이 많습니다. 예를 들어
PackageJson타입이나Jsonify<T>(T 타입을 JSON 직렬화 가능한 형태로 변환) 등등 실무에 쓸만한 타입 정의가 들어 있습니다. 프로젝트에서 복잡한 타입 변환이 필요할 때 참고하거나 가져다 쓸 수 있습니다. -
ts-toolbelt / utility-types: Type-fest와 유사한 타입 유틸리티 모음들입니다. TypeScript의 제한을 우회하는 더 트릭시한 타입들도 포함하고 있습니다. 다만 너무 복잡한 타입은 가독성을 떨어뜨릴 수 있으니, 필요할 때만 사용하는 게 좋습니다.
-
Webpack / Rollup / SWC: 프론트엔드 프로젝트에서 TS를 번들링하려면 Webpack이나 Rollup에 TS 로더를 넣어 사용합니다. 최근에는 SWC나 esbuild 같은 툴이 TypeScript 트랜스파일을 지원하여 매우 빠른 속도로 빌드를 수행하기도 합니다. 예컨대 Vite (Rollup 기반 dev 서버)는 esbuild로 TS 변환을 하고 있습니다.
-
Babel: Babel도
@babel/preset-typescript로 TS 코드를 트랜스파일할 수 있습니다. Babel의 장점은 기존 Babel 플러그인들을 같이 활용하면서 TS를 처리한다는 것이지만, TS 고유의 타입 검사 기능은 Babel이 수행하지 않으므로, 이런 경우fork-ts-checker-webpack-plugin같은 별도 타입 체크 도구를 써야 합니다. 주로 React 프로젝트에서 Babel 파이프라인에 TS를 섞을 때 쓰입니다. -
DefinitelyTyped: 일종의 라이브러리는 아니지만, @types 소스가 모여있는 GitHub 레포지토리입니다. 가끔 @types 패키지의 타입이 잘못되었거나 최신과 안 맞을 때, 이 저장소에 PR을 보내 수정하거나, 이슈를 올려 커뮤니티의 도움을 받을 수 있습니다. TypeScript 사용자라면 한 번쯤 들러볼만한 곳입니다.
-
API Extractor: 마이크로소프트에서 만든 도구로, 라이브러리에서 export하는 타입들(.d.ts)을 분석해서 리포트와 rollup (하나의 .d.ts로 합치는) 등을 해줍니다. 라이브러리 개발시 공개 API의 영향을 추적하고 버전관리에 도움을 줍니다.
-
tsconfig paths: TS에서
paths옵션을 써서 import 경로 별칭을 지정할 수 있는데, Node나 Webpack에서 이를 인식시키려면 별도 조정이 필요합니다.tsconfig-paths패키지는 ts-node나 runtime에서 TS의 path mapping을 처리해주는 유틸리티입니다. -
nestjs (네스트JS): Node.js 서버 프레임워크 중 TypeScript를 아예 전제하고 만들어진 풀스택 프레임워크입니다. 객체지향에 익숙한 개발자나 대규모 서버 구축에 좋은 구조를 제공하며, TS를 적극 활용합니다. (Express 위에 구축되어 있지만 구조가 다릅니다.)
이 밖에도 TypeScript 생태계에는 생산성을 높여주는 도구들이 많이 있습니다. 무엇보다도 TS 자체가 빠르게 발전하고 있어서, 기존 도구들이 TS 최신 버전을 따라가지 못하는 경우도 생기므로 항상 호환성 체크를 해야 합니다. (예: TS 4.5에서 ECMAScript 모듈 호환 이슈 등)
마지막으로, 공식 TypeScript 사이트와 커뮤니티도 하나의 자원입니다. Handbook, Tutorial, Playground 등을 활용해서 모르는 부분을 실험해보고, Stack Overflow나 GitHub Discussions에서 다른 개발자들의 질문/답변을 찾아보는 것도 큰 도움이 됩니다. TypeScript는 전 세계적으로 사용되며 문서와 자료도 풍부하므로, 어려운 문제가 생겼을 때 검색해보면 대부분 해결책이나 가이드가 나오는 편입니다.
이상으로 TypeScript의 입문부터 고급까지 핵심 내용을 모두 다루어 보았습니다. 처음에는 익숙하지 않더라도, 점차 사용하다 보면 강력한 타입 시스템이 가져다주는 안정성과 편의성에 익숙해질 것입니다. 이 매뉴얼이 TypeScript 학습과 활용에 도움이 되기를 바랍니다.