TypeScript 입문: JavaScript에 타입이 필요한 이유

JavaScript로 작은 프로그램을 만들 때는 타입을 크게 의식하지 않아도 됩니다. 변수에 문자열을 넣었다가 숫자를 넣을 수도 있고, 함수에 어떤 값을 넘기든 일단 실행은 되니까요. 문제는 프로그램이 커진 뒤에 시작됩니다.
function formatPrice(price) {
return price.toFixed(2);
}
formatPrice("12000");
formatPrice()는 숫자를 기대하지만 문자열을 받았습니다. JavaScript는 해당 줄을 실행한 뒤에야 toFixed is not a function이라는 오류를 냅니다. 이 함수가 주문 화면 깊숙한 곳에서만 호출된다면 배포 전에 문제를 발견하지 못할 수도 있겠죠. 💥
TypeScript는 이런 실수를 코드를 실행하기 전에 찾아줍니다. 이번 글에서는 TypeScript가 무엇인지부터 타입 추론과 객체 타입, 유니온 타입, 타입 좁히기, 제네릭까지 차근차근 살펴보겠습니다. 마지막에는 Bun으로 작은 프로젝트를 만들어 직접 실행해 볼 거예요.
TypeScript는 어떤 언어인가요?
TypeScript는 JavaScript에 타입 문법을 더한 프로그래밍 언어입니다. Microsoft가 개발하고 있으며, 공식 문서에서는 TypeScript를 “JavaScript를 위한 정적 타입 검사기”라고 설명합니다.
정적 타입 검사(static type checking)란 프로그램을 실행하기 전에 값이 올바른 방식으로 사용되는지 확인하는 과정입니다. 앞의 가격 예제에 매개변수 타입을 표시해 볼까요?
function formatPrice(price: number): string {
return price.toFixed(2);
}
formatPrice("12000");
// ^^^^^^^ Argument of type 'string' is not assignable to parameter of type 'number'.
price: number는 price에 숫자만 받을 수 있다는 뜻이고, 함수 뒤의 : string은 문자열을 반환한다는 뜻입니다. 문자열을 넘기는 실수는 편집기와 TypeScript 컴파일러가 바로 알려줍니다. 사용자가 오류를 마주치기 전에 고칠 수 있죠.
TypeScript는 JavaScript의 상위 집합(superset)이기도 합니다. JavaScript 문법은 TypeScript에서도 유효합니다. 기존 .js 파일의 확장자를 .ts로 바꾸고 필요한 곳부터 타입을 붙일 수 있어 오래된 프로젝트에도 조금씩 도입하기 좋습니다.
다만 TypeScript가 JavaScript를 대신하는 런타임은 아닙니다. 브라우저와 대부분의 JavaScript 런타임은 TypeScript의 타입 문법을 그대로 이해하지 못합니다. TypeScript 코드는 실행 전에 JavaScript로 변환되어야 해요.
타입은 실행할 때 사라집니다
TypeScript를 이해하려면 먼저 타입이 개발 도구를 위한 정보이며 실행 결과에는 남지 않는다는 사실을 알아야 합니다.
다음 TypeScript 코드를 보겠습니다.
interface User {
name: string;
age: number;
}
function introduce(user: User): string {
return `${user.name}은 ${user.age}살입니다.`;
}
JavaScript로 컴파일하면 interface와 매개변수, 반환값의 타입 표시가 모두 사라집니다.
function introduce(user) {
return `${user.name}은 ${user.age}살입니다.`;
}
이를 타입 소거(type erasure)라고 합니다. 타입 검사는 컴파일 시점(compile time)에만 일어나고, 프로그램이 실행되는 런타임(runtime)에는 평범한 JavaScript만 남습니다.
TypeScript가 API 응답이나 폼 입력을 알아서 검증해 줄 거라는 오해도 여기서 생깁니다. 외부에서 받은 값은 런타임에 들어오므로 타입만으로는 안전성을 보장할 수 없습니다.
interface User {
name: string;
age: number;
}
const response = await fetch("/api/user/1");
const user = (await response.json()) as User;
as User는 데이터를 검사하지 않습니다. 개발자가 “이 값은 User라고 믿어도 된다”고 TypeScript에 알려주는 타입 단언(type assertion)일 뿐이에요. 서버가 { "name": "Dale" }처럼 age가 없는 값을 보내도 오류를 막아주지 못합니다. 이런 경계에서는 직접 값을 확인하거나 스키마 검증 도구를 써야 합니다. 자세한 이유는 TypeScript를 써도 유효성 검증이 필요한 이유에서 확인할 수 있습니다.
프로젝트 시작하기
먼저 빈 디렉터리를 만들고 Bun 프로젝트를 초기화하겠습니다. bun init은 package.json과 tsconfig.json을 함께 만들어 줍니다.
mkdir hello-typescript
cd hello-typescript
bun init -y
TypeScript 컴파일러는 개발 의존성(development dependency)으로 설치합니다. 코드를 검사하고 변환할 때만 필요하기 때문입니다.
bun add -d typescript
tsconfig.json은 TypeScript 컴파일러가 어떤 파일을 검사하고 어떤 JavaScript를 출력할지 정하는 설정 파일입니다. 기존 JavaScript 프로젝트에 TypeScript만 따로 설치했다면 bunx tsc --init으로 만들 수 있어요. 입문 단계에서는 모든 옵션을 외울 필요가 없습니다. Bun이 만든 설정을 다음처럼 간단히 다듬어도 충분합니다.
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"strict": true,
"noEmit": true
},
"include": ["src"]
}
target은 출력할 JavaScript 문법 수준을 정하고 module은 모듈 형식을 정합니다. strict는 엄격한 타입 검사를 한꺼번에 켜요. noEmit은 JavaScript 파일을 만들지 않고 타입만 검사합니다. Bun처럼 TypeScript 파일을 직접 실행하는 런타임이나 Vite 같은 빌드 도구를 쓴다면 tsc는 타입 검사만 맡기는 구성이 흔합니다.
src/index.ts를 만들고 다음 코드를 넣어 보세요.
const message = "TypeScript를 시작합니다!";
console.log(message);
타입 오류는 다음 명령으로 확인합니다.
bunx tsc
Bun에서는 .ts 파일을 바로 실행할 수 있습니다.
bun src/index.ts
여기서 bun과 tsc의 역할을 구분해야 합니다. Bun은 TypeScript 문법을 걷어내고 코드를 실행하지만 완전한 타입 검사를 대신하지는 않습니다. bunx tsc로 타입을 검사하고 bun으로 실행하는 두 단계를 함께 쓰는 이유입니다. JavaScript 파일을 직접 출력해야 하는 프로젝트라면 noEmit을 빼고 타입스크립트 컴파일러 사용법을 참고해 보세요.
기본 타입과 타입 추론
TypeScript에서 자주 만나는 원시 타입은 string, number, boolean입니다. 배열은 string[]처럼 요소 타입 뒤에 대괄호를 붙여 표현할 수 있어요.
const name: string = "Dale";
const age: number = 20;
const learning: boolean = true;
const languages: string[] = ["JavaScript", "TypeScript"];
그런데 위 코드는 타입을 지나치게 많이 적었습니다. TypeScript는 변수에 들어온 값을 보고 타입을 알아낼 수 있거든요. 이를 타입 추론(type inference)이라고 합니다.
const name = "Dale"; // string으로 추론
const age = 20; // number로 추론
const learning = true; // boolean으로 추론
const languages = ["JavaScript", "TypeScript"]; // string[]으로 추론
타입이 명확할 때는 추론에 맡기는 편이 읽기 쉽고 수정하기도 편합니다. 반면 함수 매개변수는 호출 전까지 들어올 값을 알 수 없으므로 타입을 직접 적어야 합니다. 여러 곳에서 쓰는 공개 함수라면 반환 타입도 표시해 계약을 분명히 하는 게 좋아요.
function greet(name: string): string {
return `안녕하세요, ${name}님!`;
}
TypeScript에는 모든 타입 검사를 피하는 any도 있습니다. any인 값에는 존재하지 않는 속성을 읽거나 엉뚱한 함수에 넘겨도 오류가 나지 않습니다.
let value: any = "hello";
value.notFound.deep.call(); // 타입 오류가 나지 않음
급한 마음에 오류가 나는 곳마다 any를 붙이면 TypeScript를 쓰는 장점이 사라집니다. 타입을 모르는 값에는 unknown을 쓰세요. unknown은 사용하기 전에 값의 정체를 확인하도록 강제합니다.
function printLength(value: unknown): void {
if (typeof value === "string") {
console.log(value.length);
}
}
객체의 모양 표현하기
JavaScript 애플리케이션에서는 원시 값보다 객체를 더 자주 다룹니다. TypeScript는 객체가 가져야 할 속성과 각 속성의 타입을 표현할 수 있어요.
interface User {
id: number;
name: string;
email?: string;
}
function printUser(user: User): void {
console.log(`${user.id}: ${user.name}`);
}
email?의 물음표는 이 속성이 없어도 된다는 뜻입니다. id나 name이 빠지거나 타입이 다른 객체를 printUser()에 넘기면 TypeScript가 오류를 냅니다.
printUser({ id: 1, name: "Dale" }); // 정상
printUser({ id: "1", name: "Dale" }); // id는 number여야 함
같은 객체 모양을 type으로도 표현할 수 있습니다.
type User = {
id: number;
name: string;
email?: string;
};
둘 중 하나만 정답인 것은 아닙니다. interface는 같은 이름의 선언을 합치거나 extends로 객체 계약을 확장할 수 있습니다. type은 객체뿐 아니라 유니온 타입(union type)과 튜플(tuple), 원시 타입의 별칭도 나타낼 수 있고요. 처음에는 확장될 객체 계약에는 interface, 그 밖의 조합에는 type을 쓴다고 정해 두면 선택이 쉬워집니다.
TypeScript는 이름보다 객체의 실제 모양을 비교합니다. 이를 구조적 타입 시스템(structural type system)이라고 합니다.
interface Named {
name: string;
}
const developer = {
name: "Dale",
language: "TypeScript",
};
function sayName(value: Named): void {
console.log(value.name);
}
sayName(developer); // name이 있으므로 전달 가능
developer를 Named라고 선언하지 않았어도 필요한 name 속성이 있으므로 전달할 수 있습니다. JavaScript의 유연한 객체 사용 방식을 타입으로 옮긴 설계입니다.
여러 가능성은 유니온 타입으로
값이 한 가지 타입만 갖는다고 단정할 수 없을 때는 세로줄 |로 가능성을 연결합니다. 이것이 유니온 타입입니다.
function formatId(id: string | number): string {
return String(id).toUpperCase();
}
id는 문자열이나 숫자가 될 수 있습니다. 그래서 TypeScript는 두 타입에 공통으로 안전한 동작만 허용합니다. 문자열에만 있는 toUpperCase()를 곧바로 호출하면 숫자일 가능성이 남아 있어 오류가 납니다.
각 경우를 다르게 처리하려면 타입을 좁혀야 합니다.
function formatId(id: string | number): string {
if (typeof id === "string") {
return id.toUpperCase();
}
return id.toString();
}
typeof id === "string" 조건 안에서 TypeScript는 id가 문자열임을 압니다. 조건을 통과하지 않은 아래쪽에서는 숫자라고 판단하고요. 이렇게 실행 흐름을 따라 더 구체적인 타입을 알아내는 과정을 타입 좁히기(type narrowing)라고 합니다. typeof 외에도 instanceof, in, Array.isArray() 같은 JavaScript 검사를 활용할 수 있습니다.
상태를 모델링할 때는 문자열 리터럴 타입(literal type)을 조합하면 편리합니다.
type RequestState = "idle" | "loading" | "success" | "error";
function showState(state: RequestState): string {
switch (state) {
case "idle":
return "요청 전";
case "loading":
return "불러오는 중";
case "success":
return "완료";
case "error":
return "실패";
}
}
아무 문자열이나 받는 대신 허용된 네 값만 받습니다. 오타를 막아주고 편집기의 자동 완성도 받을 수 있어요.
제네릭으로 타입 관계 보존하기
입력값을 그대로 돌려주는 함수를 만들어 보겠습니다. unknown으로 쓰면 어떤 값이든 받을 수 있지만 반환값의 구체적인 타입을 잃어버립니다.
function identity(value: unknown): unknown {
return value;
}
const result = identity("hello"); // unknown
이럴 때 제네릭(generic)을 사용합니다. 제네릭은 나중에 정해질 타입을 위한 매개변수입니다.
function identity<T>(value: T): T {
return value;
}
const text = identity("hello"); // string
const count = identity(3); // number
호출할 때 들어온 값에 따라 T가 결정되고 반환 타입도 같은 T가 됩니다. “어떤 타입이든 받는다”에서 끝나지 않고 “입력 타입과 반환 타입이 같다”는 관계까지 표현하는 것이죠.
제네릭은 배열이나 API 응답처럼 같은 구조 안의 값만 바뀔 때 특히 유용합니다.
interface ApiResponse<T> {
data: T;
status: number;
}
interface User {
id: number;
name: string;
}
const response: ApiResponse<User> = {
data: { id: 1, name: "Dale" },
status: 200,
};
제네릭은 타입 매개변수가 실제 관계를 나타낼 때만 써야 합니다. 한 번만 등장하는 T는 굳이 필요하지 않은 경우가 많아요. 입력과 출력이나 컨테이너와 내용물 사이의 관계를 보존할 때 사용한다고 기억해 두세요.
strict를 처음부터 켜세요
TypeScript는 JavaScript 프로젝트가 조금씩 타입을 도입할 수 있도록 상당히 느슨하게 시작할 수 있습니다. 하지만 새 프로젝트라면 tsconfig.json에서 strict를 켜는 것이 좋습니다.
{
"compilerOptions": {
"strict": true
}
}
strict는 암시적인 any, null과 undefined, 함수 타입 등과 관련된 엄격한 검사 옵션을 묶어서 활성화합니다. 예를 들어 다음 함수의 name에는 타입이 없습니다.
function greet(name) {
return `안녕하세요, ${name}님!`;
}
엄격한 모드에서는 name이 암시적으로 any가 된다는 오류가 납니다. name: string을 적으면 문제가 해결되죠.
처음에는 빨간 줄이 늘어나는 것처럼 보여도 일찍 켜는 편이 낫습니다. 느슨한 코드가 쌓인 뒤 엄격한 모드로 바꾸면 수백 개의 오류를 한꺼번에 고쳐야 할 수 있거든요. 외부 값은 unknown으로 받고 없을 수 있는 값은 string | undefined처럼 드러내는 습관도 생깁니다.
TypeScript가 해결하지 않는 것
TypeScript가 있으면 모든 오류가 사라질까요? 그렇지는 않습니다.
타입이 맞아도 로직은 틀릴 수 있습니다. 두 숫자를 더해야 하는 함수가 빼기를 해도 반환값은 여전히 number라 타입 오류가 나지 않습니다. 테스트가 필요한 이유죠.
외부 데이터도 자동으로 안전해지지 않습니다. API 응답, 환경 변수, 로컬 저장소, 폼 입력은 런타임에 들어옵니다. 타입 단언으로 덮지 말고 실제 값을 검사해야 합니다.
TypeScript는 코드 설계도 대신하지 않습니다. 복잡한 타입으로 나쁜 구조를 감싸면 오히려 읽기 어려워져요. 오류를 없애려고 any나 무리한 타입 단언을 추가했다면 검사를 끈 것은 아닌지 돌아봐야 합니다.
마치며
TypeScript는 JavaScript와 전혀 다른 세계가 아닙니다. 우리가 아는 JavaScript 위에 정적 타입 검사를 얹어, 잘못된 값이 흘러가는 문제를 실행 전에 알려주는 도구에 가깝습니다.
처음에는 string, number, 배열과 객체 타입부터 익혀 보세요. 그다음 유니온 타입과 타입 좁히기로 여러 상태를 안전하게 다루고, 입력과 출력의 관계를 보존해야 할 때 제네릭을 꺼내면 됩니다. 타입이 명확한 값은 추론에 맡기고 any 대신 unknown을 선택하는 습관도 중요하고요.
이제 작은 JavaScript 파일 하나를 .ts로 바꾸고 strict를 켜 보세요. 편집기가 알려주는 첫 타입 오류를 고치는 순간부터 TypeScript가 왜 유용한지 금방 체감할 수 있을 겁니다. 더 자세한 언어 기능은 TypeScript 공식 핸드북을 참고하세요.
This work is licensed under CC BY 4.0