TypeScript 시작하기 | 설치, 설정, 기본 문법
이 글의 핵심
JavaScript에서는 실행해 봐야 드러나던 오타와 타입 실수를 컴파일 단계에서 잡아 주는 것이 TypeScript를 쓰는 이유입니다. Node.js와 tsc 설치, tsconfig 주요 옵션, ts-node·nodemon 개발 환경을 갖춘 뒤 간단한 계산기 예제로 마무리합니다. 타입 주석과 as 단언, satisfies가 각각 무엇을 보장하는지도 짚습니다.
들어가며
TypeScript란?
TypeScript는 Microsoft가 개발한 JavaScript의 상위 집합(Superset) 언어입니다.
TypeScript = JavaScript + 타입 시스템
타입은 값마다 붙는 명찰과 같습니다. 컴파일 시점(TypeScript를 JavaScript로 바꿀 때)에 “이 값은 문자열인가, 숫자인가”를 명찰로 확인하기 때문에, 잘못된 연산을 미리 걸러 내기 쉽습니다.
주요 특징:
- 정적 타입: 컴파일(TypeScript를 JavaScript로 바꿀 때) 시 타입 체크
- 자동완성: IDE 지원 강화
- 리팩토링: 안전한 코드 변경
- 최신 문법: ES6+ 지원
- 호환성: JavaScript로 컴파일
JavaScript에서 TypeScript로 옮겨 오며 느낀 점
“런타임 에러는 프로덕션에서 터진다”는 말이 있습니다. JavaScript로 개발하던 시절, 이 말을 뼈저리게 체감했습니다. 테스트는 다 통과했는데 배포 후 사용자가 이상한 데이터를 입력하면 앱이 터지는 겁니다. 특히 팀 프로젝트에서 다른 사람이 작성한 함수의 반환 타입을 잘못 이해해서 버그가 발생하는 일이 잦았습니다.
TypeScript를 도입한 후 가장 먼저 느낀 건 안심이었습니다. IDE가 자동완성을 정확히 해주고, 타입이 맞지 않으면 빨간 줄로 미리 알려줍니다. 리팩토링할 때도 “이 함수를 바꾸면 어디가 깨질까?” 걱정할 필요가 없어졌습니다. 물론 처음엔 타입 정의 작성이 번거로웠지만, 몇 달 후 그 타입 정의 덕분에 버그를 미리 잡는 경험을 하고 나니 돌아갈 수 없었습니다.
TypeScript와 JavaScript 비교
비교
| 특징 | JavaScript | TypeScript |
|---|---|---|
| 타입 | 동적 타입 | 정적 타입 |
| 컴파일 | 불필요 | 필요 (→ JS) |
| 에러 검출 | 런타임(프로그램이 실제로 실행되는 때) | 컴파일 타임(빌드·변환할 때) |
| IDE 지원 | 기본 | 강력 |
| 학습 곡선 | 낮음 | 중간 |
예제
JavaScript와 TypeScript의 타입 안정성 차이를 보여주는 예제입니다:
// JavaScript: 동적 타입
function add(a, b) {
// a, b의 타입이 명시되지 않음
// 어떤 타입이든 받을 수 있음
return a + b;
}
console.log(add(10, 20)); // 30 (숫자 덧셈)
console.log(add("10", "20")); // "1020" (문자열 연결)
// 의도하지 않은 결과!
// 런타임에 에러 없이 실행되지만 논리적 버그
// 타입 체크 없이 실행
console.log(add(10, "20")); // "1020" (숫자 + 문자열)
console.log(add(null, 10)); // 10 (null은 0으로 변환)
console.log(add(undefined, 10)); // NaN (undefined는 NaN으로)
// 예상치 못한 동작들
// TypeScript: 정적 타입
function add(a: number, b: number): number {
// a: number - a는 number 타입만 받음
// b: number - b는 number 타입만 받음
// : number - 반환 타입도 number
return a + b;
}
console.log(add(10, 20)); // ✅ 30 (정상)
// 컴파일 타임에 에러 검출
console.log(add("10", "20")); // ❌ 컴파일 에러!
// 에러 메시지:
// Argument of type 'string' is not assignable to parameter of type 'number'
//
// 코드 실행 전에 에러 발견 (안전)
// 다른 타입도 모두 에러
// console.log(add(10, "20")); // ❌ 에러
// console.log(add(null, 10)); // ❌ 에러
// console.log(add(undefined, 10)); // ❌ 에러
TypeScript의 장점:
- 컴파일 타임 에러 검출: 실행 전에 버그 발견
- 자동완성: IDE가 타입을 알아서 정확한 제안
- 리팩토링: 타입 변경 시 모든 사용처 추적
- 문서화: 함수가 무엇을 받고 무엇을 돌려주는지가 시그니처에 드러남 (왜 그렇게 동작하는지는 여전히 주석이 필요)
주석 처리한 add(null, 10)이 에러가 되는 것은 tsconfig.json에서 strict(그 안의 strictNullChecks)를 켰을 때의 이야기입니다. 이 옵션이 꺼져 있으면 null과 undefined가 모든 타입에 들어갈 수 있어서 add(null, 10)이 그대로 통과합니다. TypeScript를 쓰는데도 “Cannot read properties of undefined” 런타임 에러가 계속 난다면 가장 먼저 strict가 켜져 있는지 확인해야 합니다. 새 프로젝트라면 처음부터 켜 두는 것이 사실상 표준이며, tsc --init이 만드는 설정도 기본으로 켜져 있습니다.
더 근본적인 한계도 있습니다. TypeScript의 타입은 컴파일할 때만 존재하고, 생성된 JavaScript에서는 완전히 지워집니다. add(a: number, b: number)는 컴파일 후 add(a, b)가 될 뿐 실행 중에 인자를 검사하는 코드는 한 줄도 추가되지 않습니다. 따라서 사용자 입력, JSON.parse 결과, 외부 API 응답처럼 프로그램 밖에서 들어오는 값에 대해서는 타입이 아무것도 보장하지 않습니다.
실전 예시:
// API 응답 타입 정의
interface User {
id: number;
name: string;
email: string;
}
function getUser(id: number): User {
// 반환 타입이 User로 보장됨
return {
id: id,
name: "홍길동",
email: "[email protected]"
};
}
const user = getUser(1);
console.log(user.name); // IDE가 name을 자동완성
// console.log(user.age); // ❌ 에러: User에 age 없음
이 예제는 객체를 코드 안에서 직접 만들기 때문에 타입이 실제 값과 일치합니다. 실제 API 호출로 바꾸면 사정이 달라집니다. const user = (await res.json()) as User;처럼 쓰는 코드가 흔한데, res.json()의 결과는 any이고 as는 검사가 아니라 “내가 보장한다”는 선언이므로, 서버가 name 대신 userName을 보내도 컴파일러는 모릅니다. 처음 TypeScript를 도입한 팀이 가장 많이 겪는 실망이 “타입을 다 붙였는데 왜 여전히 undefined 에러가 나는가”이고, 원인의 상당수가 이 경계 지점입니다. 외부 데이터는 zod 같은 런타임 검증 라이브러리로 모양을 확인한 뒤 타입을 얻는 방식이 이 틈을 메웁니다.
Node.js와 TypeScript 설치, 프로젝트 초기화
Node.js 설치
TypeScript는 Node.js 환경에서 실행됩니다.
- Node.js 공식 사이트에서 다운로드
- 설치 확인:
node --version
npm --version
TypeScript 설치
# 전역 설치
npm install -g typescript
# 버전 확인
tsc --version
프로젝트 초기화
# 프로젝트 폴더 생성
mkdir my-typescript-project
cd my-typescript-project
# package.json 생성
npm init -y
# TypeScript 설치 (로컬)
npm install --save-dev typescript
# tsconfig.json 생성
npx tsc --init
전역 설치(-g)와 프로젝트 로컬 설치(--save-dev)를 둘 다 소개했지만, 실무에서는 로컬 설치를 기본으로 씁니다. 전역 tsc는 컴퓨터마다 버전이 다를 수 있어서, 내 PC에서는 통과하던 코드가 동료의 PC나 CI에서는 새 버전의 더 엄격한 검사에 걸리는 일이 생깁니다. package.json에 버전을 고정한 로컬 설치와 npx tsc(또는 npm 스크립트)를 쓰면 모두가 같은 컴파일러를 쓰게 됩니다. VS Code도 기본적으로 자체 내장 TypeScript 버전을 쓰므로, 에디터와 터미널의 에러가 다르다면 명령 팔레트의 “TypeScript: Select TypeScript Version”에서 작업 영역 버전을 선택하면 됩니다.
tsconfig.json 주요 옵션
기본 설정
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
주요 옵션 설명
| 옵션 | 설명 |
|---|---|
target | 컴파일 대상 JavaScript 버전 |
module | 모듈 시스템 (commonjs, es6) |
outDir | 컴파일된 파일 출력 폴더 |
rootDir | 소스 파일 폴더 |
strict | 엄격한 타입 체크 활성화 |
esModuleInterop | CommonJS/ES6 모듈 호환성 |
위 설정은 tsc로 컴파일해 Node.js에서 바로 실행하는 가장 단순한 경우입니다. 요즘 프로젝트는 조합이 조금 다릅니다. Vite나 Next.js처럼 번들러가 최종 결과물을 만드는 프런트엔드 프로젝트라면 TypeScript 5.0에 추가된 "moduleResolution": "bundler"와 "module": "ESNext"를 쓰고, 변환은 번들러에 맡긴 채 tsc는 타입 검사만 하도록 "noEmit": true를 둡니다. 반대로 ESM으로 작성한 Node.js 라이브러리나 서버라면 "module": "NodeNext"가 맞는데, 이 경우 상대 경로 import에 ./util.js처럼 확장자를 붙여야 한다는 점에서 처음에 많이 막힙니다. 프레임워크의 프로젝트 생성 도구가 만들어 주는 tsconfig를 출발점으로 삼고, 옵션을 하나씩 이해해 가는 편이 가장 빠릅니다.
첫 프로그램 작성과 tsc 컴파일
프로젝트 구조
my-typescript-project/
├── src/
│ └── index.ts
├── dist/
├── package.json
└── tsconfig.json
index.ts 작성
// src/index.ts
function greet(name: string): string {
return `Hello, ${name}!`;
}
const userName: string = "홍길동";
console.log(greet(userName));
// 타입 에러 예제
// console.log(greet(123)); // ❌ 에러: number는 string이 아님
컴파일 및 실행
# TypeScript → JavaScript 컴파일
npx tsc
# 컴파일된 파일 실행
node dist/index.js
출력:
Hello, 홍길동!
tsc는 타입 에러가 있어도 기본적으로 JavaScript 파일을 출력합니다. 에러 메시지가 잔뜩 나왔는데 dist/index.js가 멀쩡히 생기고 실행까지 되는 것을 보고 당황하는 경우가 많습니다. TypeScript는 타입 검사와 변환을 별개의 작업으로 보기 때문이며, 에러가 있을 때 출력을 막으려면 "noEmitOnError": true를 설정합니다. CI에서는 tsc --noEmit으로 타입 검사만 하고 실패 시 빌드를 멈추게 하는 방식이 흔합니다.
이 단계에서 처음 만나기 쉬운 에러가 하나 더 있습니다. src 폴더에 index.ts와 다른 예제 파일을 함께 두고, 두 파일에 모두 function add를 만들면 Duplicate function implementation(TS2393) 에러가 납니다. import나 export가 없는 파일은 모듈이 아니라 전역 스크립트로 취급되어, 모든 파일이 같은 전역 스코프를 공유하기 때문입니다. 파일 끝에 export {};를 한 줄 넣거나, TypeScript 4.7에 추가된 "moduleDetection": "force" 옵션을 켜면 각 파일이 독립된 모듈이 되어 충돌이 사라집니다.
원시 타입·배열·튜플·any와 unknown
원시 타입
// 숫자
let age: number = 25;
let price: number = 19.99;
// 문자열
let name: string = "홍길동";
let message: string = `안녕하세요, ${name}님!`;
// 불리언
let isActive: boolean = true;
let isCompleted: boolean = false;
// null과 undefined
let empty: null = null;
let notDefined: undefined = undefined;
배열
// 방법 1: 타입[]
let numbers: number[] = [1, 2, 3, 4, 5];
let names: string[] = ["홍길동", "김철수", "이영희"];
// 방법 2: Array<타입>
let scores: Array<number> = [90, 85, 95];
// 다차원 배열
let matrix: number[][] = [
[1, 2, 3],
[4, 5, 6]
];
튜플
// 튜플: 고정된 길이와 타입의 배열
let person: [string, number] = ["홍길동", 25];
console.log(person[0]); // 홍길동
console.log(person[1]); // 25
// person[2] = "서울"; // ❌ 에러: 길이 초과
튜플은 함수가 값 두세 개를 묶어 반환할 때(React의 useState가 [값, 설정함수]를 돌려주는 것처럼) 유용하지만, 원소에 이름이 없어 person[1]이 나이인지 읽는 사람이 기억해야 합니다. 원소가 세 개를 넘거나 여러 곳에서 쓰인다면 객체 타입이 더 읽기 쉽습니다. 또 튜플의 길이 검사는 인덱스 접근에만 적용됩니다. person[2] = "서울"은 “Tuple type ‘[string, number]’ of length ‘2’ has no element at index ‘2’” 에러가 나지만, person.push("서울")은 통과해서 실제 배열 길이가 3이 됩니다. 이를 막으려면 readonly [string, number]로 선언합니다.
any와 unknown은 “무엇이든 들어갈 수 있다”는 점은 같지만, 꺼내 쓸 때가 다릅니다. any는 타입 검사를 완전히 꺼 버리므로 anything.foo.bar()처럼 존재하지 않는 속성을 호출해도 컴파일이 통과하고, 그 값이 다른 변수로 흘러가면 그쪽의 타입 검사까지 무력화됩니다. unknown은 위 예제처럼 typeof나 instanceof로 좁히기 전에는 아무것도 할 수 없게 막습니다. 타입을 모르는 값을 받아야 할 때는 unknown을 기본으로 쓰고, any는 타입 정의가 없는 오래된 라이브러리를 임시로 감쌀 때처럼 이유가 분명할 때만 쓰는 것이 좋습니다.
any와 unknown
// any: 모든 타입 허용 (타입 체크 비활성화)
let anything: any = 10;
anything = "문자열";
anything = true;
// unknown: 타입 안전한 any
let value: unknown = 10;
// console.log(value.toFixed(2)); // ❌ 에러
// 타입 체크 후 사용
if (typeof value === "number") {
console.log(value.toFixed(2)); // ✅ OK
}
타입 확인만 하고 싶을 때: satisfies
설정 객체처럼 “이 모양을 지켰는지 확인하되, 값의 구체적인 타입은 그대로 두고 싶은” 경우가 자주 있습니다. const config: Record<string, string | number> = {...}처럼 타입을 붙이면 검사는 되지만 config.port의 타입이 string | number로 넓어져 숫자 메서드를 바로 쓸 수 없고, as로 단언하면 오타가 있어도 통과해 버립니다. TypeScript 4.9에서 추가된 satisfies는 모양은 검사하면서 추론된 구체 타입을 유지합니다.
type Config = Record<string, string | number>;
const config = {
host: "localhost",
port: 3000,
} satisfies Config; // 모양 검사는 하고
config.port.toFixed(0); // ✅ port는 여전히 number로 추론됨
// const bad = { port: true } satisfies Config; // ❌ boolean은 허용되지 않아 에러
함수 매개변수와 반환 타입
기본 함수
// 매개변수와 반환 타입
function add(a: number, b: number): number {
return a + b;
}
// 화살표 함수
const subtract = (a: number, b: number): number => {
return a - b;
};
// 반환값 없음
function log(message: string): void {
console.log(message);
}
선택적 매개변수
function greet(name: string, greeting?: string): string {
if (greeting) {
return `${greeting}, ${name}!`;
}
return `Hello, ${name}!`;
}
console.log(greet("홍길동")); // Hello, 홍길동!
console.log(greet("홍길동", "안녕")); // 안녕, 홍길동!
기본 매개변수
function createUser(name: string, age: number = 20): object {
return { name, age };
}
console.log(createUser("홍길동")); // { name: '홍길동', age: 20 }
console.log(createUser("김철수", 25)); // { name: '김철수', age: 25 }
선택적 매개변수 greeting?의 실제 타입은 string | undefined입니다. 그래서 함수 안에서 greeting.toUpperCase()를 바로 호출하면 “‘greeting’ is possibly ‘undefined’” 에러가 나고, 위 코드처럼 먼저 확인해야 합니다. 선택적 매개변수는 필수 매개변수 뒤에만 올 수 있습니다. if (greeting)은 빈 문자열 ""도 거짓으로 처리한다는 점도 알아 둘 만합니다. 빈 인사말을 의도적으로 넘긴 경우를 구분해야 한다면 greeting !== undefined로 비교합니다.
createUser의 반환 타입을 object로 둔 것은 좋은 선택이 아닙니다. object는 “원시 값이 아닌 무언가”라는 뜻뿐이라, 반환값에서 createUser("홍길동").name에 접근하면 “Property ‘name’ does not exist on type ‘object’” 에러가 납니다. 반환 타입을 생략해 { name: string; age: number }로 추론되게 하거나, 인터페이스를 정의해 명시하는 것이 맞습니다. 반환 타입을 명시할지는 팀마다 다르지만, 명시하면 함수 본문을 잘못 고쳤을 때 호출하는 쪽이 아니라 함수 안에서 에러가 나서 원인을 찾기 쉬워진다는 장점이 있습니다.
VS Code·ts-node·nodemon 개발 환경
VS Code 확장
- 자동 import: VS Code에 TypeScript 지원이 내장돼 있어 별도 확장 없이도 자동 import와 자동완성이 동작합니다
- ESLint: 코드 품질
- Prettier: 코드 포맷팅
- Path Intellisense: 경로 자동완성
ts-node 설치
컴파일 없이 TypeScript 실행:
npm install --save-dev ts-node
# 실행
npx ts-node src/index.ts
nodemon 설정
파일 변경 시 자동 재실행:
npm install --save-dev nodemon
// package.json
{
"scripts": {
"dev": "nodemon --exec ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}
# 개발 모드 실행
npm run dev
ts-node는 실행할 때마다 타입 검사까지 하므로 프로젝트가 커지면 시작이 느려지고, package.json에 "type": "module"을 둔 ESM 프로젝트에서는 Unknown file extension ".ts" 에러로 곧바로 막히는 경우가 많습니다. 그래서 최근에는 타입 검사 없이 변환만 빠르게 하는 tsx(npx tsx watch src/index.ts)를 개발 실행용으로 쓰고, 타입 검사는 에디터와 tsc --noEmit에 맡기는 조합이 흔합니다. Node.js 자체도 22.6부터 실험적으로, 23.6부터는 기본으로 .ts 파일의 타입 표기를 지우고 바로 실행하는 기능을 지원합니다. 다만 이 방식은 enum처럼 JavaScript 코드로 변환이 필요한 문법은 처리하지 않습니다.
예제: 간단한 계산기
// src/calculator.ts
type Operation = 'add' | 'subtract' | 'multiply' | 'divide';
function calculate(a: number, b: number, op: Operation): number {
switch (op) {
case 'add':
return a + b;
case 'subtract':
return a - b;
case 'multiply':
return a * b;
case 'divide':
if (b === 0) {
throw new Error("0으로 나눌 수 없습니다");
}
return a / b;
default:
throw new Error(`알 수 없는 연산: ${op}`);
}
}
// 사용
console.log(calculate(10, 5, 'add')); // 15
console.log(calculate(10, 5, 'subtract')); // 5
console.log(calculate(10, 5, 'multiply')); // 50
console.log(calculate(10, 5, 'divide')); // 2
Operation을 string이 아니라 네 가지 문자열 리터럴의 유니온으로 정의한 것이 이 예제의 핵심입니다. calculate(10, 5, 'mod')는 컴파일 단계에서 “Argument of type ‘“mod”’ is not assignable to parameter of type ‘Operation’” 에러가 나므로, default 분기에 도달하는 것은 타입을 우회한 호출(as any나 외부 입력)뿐입니다. TypeScript는 switch에서 네 경우를 모두 처리하고 나면 default의 op를 never 타입으로 좁힙니다. 이를 이용해 default: const unreachable: never = op;라고 써 두면, 나중에 Operation에 'power'를 추가하고 case를 빠뜨렸을 때 그 줄에서 컴파일 에러가 나서 처리 누락을 바로 알 수 있습니다. 유니온 타입과 좁히기는 다음 글에서 자세히 다룹니다.
TypeScript 첫걸음 요약
- TypeScript: JavaScript + 타입
- 설치:
npm install -g typescript - 설정:
tsconfig.json - 컴파일:
tsc - 기본 타입: number, string, boolean, array, tuple
다음 단계
다른 언어와 비교
자주 묻는 질문 (FAQ)
Q. 타입 주석, as 단언, satisfies는 어떻게 다르나요?
A. const config: Config처럼 타입 주석을 붙이면 모양은 검사되지만 값의 타입이 선언한 타입으로 넓어져서, port가 number인데도 string | number로 다뤄야 합니다. as 단언은 컴파일러에게 믿으라고 말하는 것이라 오타나 잘못된 값이 있어도 통과해 버립니다. TypeScript 4.9에 추가된 satisfies는 모양을 검사하면서 추론된 구체 타입을 유지하므로 설정 객체에 특히 잘 맞습니다.