지난 시간에는 기본 타입들을 정리하면서 객체 타입을 인라인으로 적었습니다. 그런데 같은 모양을 여러 곳에서 써야 할 때 매번 인라인으로 적으면 코드가 금방 지저분해지죠. 이번 글에서는 객체와 함수의 타입 모양에 이름을 붙여 재사용하는 두 도구 — **interface**와 **type alias**를 다룹니다.
인라인 타입의 한계
다음 코드를 보세요.
function logUser(user: { id: string; name: string; email: string }): void {
console.log(user.name);
}
function saveUser(user: { id: string; name: string; email: string }): void {
// ...
}
const me: { id: string; name: string; email: string } = {
id: 'u-1', name: '철수', email: 'cheolsu@example.com',
};{ id: string; name: string; email: string }이 세 번 반복됩니다. 필드를 하나 추가하려면 세 군데 모두 고쳐야 하고, 오타나 누락이 생기면 타입 검사가 어긋나요.
해결책 — 이 모양에 이름을 붙입니다.
type alias
type 키워드로 타입에 별명을 지을 수 있습니다.
type User = {
id: string;
name: string;
email: string;
};
function logUser(user: User): void {
console.log(user.name);
}
function saveUser(user: User): void {
// ...
}
const me: User = {
id: 'u-1', name: '철수', email: 'cheolsu@example.com',
};훨씬 깔끔하죠. User라는 이름 하나로 같은 모양을 여러 곳에서 가리킵니다.
type은 객체뿐 아니라 어떤 타입에도 별명을 붙일 수 있습니다.
type ID = string; // 원시 타입에도
type Point = [number, number]; // 튜플
type Color = 'red' | 'green' | 'blue'; // union
type Maybe<T> = T | null; // 제네릭 (#6에서)
type Callback = (err: Error | null, value: string) => void; // 함수이름 짓기 컨벤션은 보통 PascalCase(User, OrderItem)입니다.
interface
같은 객체 타입을 interface 키워드로 표현할 수도 있습니다.
interface User {
id: string;
name: string;
email: string;
}
function logUser(user: User): void {
console.log(user.name);
}위의 type alias와 거의 같은 결과입니다.
interface는 객체와 함수 형태에만 쓸 수 있다는 점이 type alias와 다릅니다 (원시 타입이나 union에는 못 씀). 그 외에는 거의 같이 동작해요.
interface vs type — 어느 쪽을 써야 하나?
가장 자주 받는 질문입니다. 결론부터 말하면:
거의 모든 경우 같이 동작한다. 작은 차이가 있긴 하지만 처음에는 신경 쓰지 않아도 된다.
대부분의 팀이 다음 둘 중 하나를 선택해 일관되게 사용합니다.
- type 우선 — 모든 곳에
type을 쓰고 객체에도 type을 씀. 이유: 일관성. React/Next.js 커뮤니티가 이쪽 경향 - interface 우선 — 객체엔
interface, 그 외엔type. 이유: 객체에 더 잘 맞고 약간의 추가 기능 (선언 병합)
이 시리즈에서는 type 우선 스타일로 진행합니다. 이유는 (1) 표현력이 더 넓어서 모든 케이스를 같은 키워드로 다룰 수 있고 (2) 최근 React/TS 커뮤니티의 다수 선택이라서요. 다만 interface도 익숙해져야 합니다 — 라이브러리 코드를 읽을 때 자주 만나니까요.
작은 차이들
1. 선언 병합 (declaration merging)
interface는 같은 이름으로 여러 번 선언되면 자동으로 합쳐집니다.
interface User {
name: string;
}
interface User {
age: number;
}
const u: User = { name: '철수', age: 30 }; // ✓ 합쳐져서 둘 다 필요type은 같은 이름으로 두 번 선언하면 에러:
type User = { name: string };
type User = { age: number }; // 🚫 에러선언 병합은 외부 라이브러리의 타입을 확장할 때 유용합니다 (Window, Express.Request 같은). 일상 코드에서는 거의 안 쓰지만 가끔 필요한 강력한 기능이에요.
2. 확장 문법
interface는 extends로 상속받는 모양:
interface Animal {
name: string;
}
interface Dog extends Animal {
breed: string;
}
const d: Dog = { name: '바둑이', breed: '진돗개' };type은 & (intersection)으로 합치는 모양:
type Animal = { name: string };
type Dog = Animal & { breed: string };
const d: Dog = { name: '바둑이', breed: '진돗개' };거의 같은 결과지만 미묘한 차이가 있어요. 충돌 케이스에서는 동작이 달라질 수 있는데 (interface는 에러로 막아주는 반면 &는 never로 만들어버리는 등) 일상적인 사용에선 큰 차이가 안 느껴집니다.
3. type만 가능한 것들
union, tuple, 함수 타입, 매핑된 타입 등은 type alias만으로 표현 가능합니다.
type Status = 'pending' | 'active' | 'deleted'; // union
type Point = [number, number]; // tuple
type Reducer<T> = (state: T, action: any) => T; // 함수 시그니처를 별명으로interface는 객체/함수 모양에만 쓸 수 있어 이런 표현은 못 합니다.
객체 타입 더 자세히
이제 type 또는 interface로 정의된 객체 타입의 다양한 표현을 살펴봅시다.
옵셔널 프로퍼티
type User = {
id: string;
name: string;
age?: number; // 있어도 되고 없어도 됨
email?: string;
};
const a: User = { id: 'u-1', name: '철수' }; // ✓
const b: User = { id: 'u-2', name: '영희', age: 28 }; // ✓
const c: User = { id: 'u-3', name: '민수', age: 35, email: 'm@x.com' };// ✓readonly
type User = {
readonly id: string;
name: string;
};
const u: User = { id: 'u-1', name: '철수' };
u.name = '영희'; // ✓
u.id = 'u-2'; // 🚫인덱스 시그니처 — 동적 키
키 이름을 미리 모를 때 사용합니다.
type StringMap = {
[key: string]: string;
};
const dict: StringMap = {
apple: '사과',
banana: '바나나',
cherry: '체리',
};
dict['durian'] = '두리안'; // 추가 가능{ [key: string]: T }는 "문자열을 키로 갖고 T 타입의 값을 갖는 객체"를 의미합니다. 보통 알려진 키 + 동적 키를 섞어 쓸 때 유용해요.
type FormData = {
name: string;
email: string;
[extra: string]: string; // 그 외 임의의 string 키도 허용
};메소드 시그니처
객체에 함수가 들어가는 모양도 표현 가능합니다.
type Counter = {
value: number;
increment(): void;
add(n: number): number;
};
const c: Counter = {
value: 0,
increment() { this.value += 1; },
add(n) { return this.value + n; },
};함수 시그니처를 프로퍼티로 적은 형태도 같은 의미입니다.
type Counter = {
value: number;
increment: () => void;
add: (n: number) => number;
};두 표기는 거의 같은데 미묘한 차이(strictFunctionTypes 옵션 하에서)가 있어요. 일상적으론 둘 다 잘 동작합니다.
함수 타입 별명
함수 시그니처에도 이름을 붙일 수 있습니다.
type Comparator = (a: number, b: number) => number;
const ascending: Comparator = (a, b) => a - b;
const descending: Comparator = (a, b) => b - a;
[3, 1, 4, 1, 5].sort(ascending);Comparator라는 이름으로 같은 함수 모양을 여러 곳에서 재사용. 콜백이 자주 등장하는 라이브러리 API에서 흔히 보이는 패턴입니다.
타입 합치기 — 실전
기존 타입을 결합해 새 타입을 만드는 패턴은 자주 등장합니다.
두 타입 합치기
type Person = { name: string; age: number };
type Employee = { company: string; salary: number };
type EmployedPerson = Person & Employee;
const me: EmployedPerson = {
name: '철수',
age: 30,
company: 'Acme',
salary: 50000,
};A & B는 "A의 모든 속성과 B의 모든 속성을 다 가진 타입"입니다.
일부 필드만 가져오기
type User = { id: string; name: string; email: string; age: number };
type UserPreview = Pick<User, 'id' | 'name'>;
// = { id: string; name: string }
type UserWithoutAge = Omit<User, 'age'>;
// = { id: string; name: string; email: string }Pick과 Omit은 타입스크립트 내장 유틸리티 타입입니다. 실전에서 정말 자주 쓰여요. #7에서 더 자세히 다룹니다.
직접 해보기 — 작은 도서관 시스템
지금까지 배운 걸 종합한 작은 예시.
type Book = {
readonly id: string;
title: string;
author: string;
publishedYear?: number;
tags: string[];
};
type Member = {
readonly id: string;
name: string;
email: string;
};
type Loan = {
bookId: string;
memberId: string;
borrowedAt: Date;
dueDate: Date;
returnedAt?: Date;
};
type LoanWithDetails = Loan & {
book: Book;
member: Member;
};
function isOverdue(loan: Loan): boolean {
if (loan.returnedAt) return false;
return new Date() > loan.dueDate;
}
const book1: Book = {
id: 'b-1',
title: '타입스크립트 입문',
author: '저자',
publishedYear: 2024,
tags: ['프로그래밍', '입문'],
};
const member1: Member = {
id: 'm-1',
name: '철수',
email: 'cheolsu@example.com',
};
const loan1: Loan = {
bookId: book1.id,
memberId: member1.id,
borrowedAt: new Date('2026-04-01'),
dueDate: new Date('2026-04-15'),
};
console.log(isOverdue(loan1)); // 오늘 날짜 기준type 정의들이 차곡차곡 쌓이고, 그 위에서 isOverdue 같은 함수가 안전하게 동작합니다. 이런 게 타입스크립트의 진짜 가치예요 — 데이터 모양을 미리 명시하고 그걸 기반으로 함수와 컴포넌트를 만드는 흐름.
흔한 함정
1. interface 중복 선언이 의도와 다르게 합쳐짐
같은 이름의 interface를 두 곳에 쓰면 자동으로 합쳐집니다. 이게 의도한 거면 좋지만, 우연히 다른 모듈에서 같은 이름을 쓰면 예상 못한 합병이 일어날 수 있어요. 큰 코드베이스에서는 type이 더 안전한 면이 있습니다.
2. 객체 리터럴의 excess property check
type User = { name: string };
const u: User = { name: '철수', age: 30 }; // 🚫 age는 User에 없음
const data = { name: '철수', age: 30 };
const v: User = data; // ✓ 객체 변수를 통해 할당하면 OK (excess check 안 함)객체 리터럴을 직접 할당할 때만 추가 속성 검사가 강합니다. 변수를 거치면 좀 더 관대해져요. 처음 보면 이상한데 의도된 동작입니다.
3. union vs intersection 혼동
A | B는 "A 또는 B 중 하나" (union, #4),
A & B는 "A와 B의 합성" (intersection).
객체에 대해서는 헷갈리기 쉬운데, intersection이 "둘 다의 속성을 가진 객체"라는 점을 기억하세요. union은 "둘 중 하나의 모양"이고요.
마무리
이번 글에서는 객체와 함수의 타입에 이름을 붙이는 두 도구를 다뤘습니다.
type— 어떤 타입이든 별명 가능 (객체, 원시, union, tuple, 함수 ...)interface— 객체/함수 모양 전용. 선언 병합 가능- 거의 모든 경우 호환되며, 팀 컨벤션 따라 일관되게 사용
- 옵셔널
?,readonly, 인덱스 시그니처 같은 객체 타입 표현 &(intersection)으로 타입 합치기,Pick/Omit으로 일부 골라 쓰기
지금까지의 타입은 하나의 모양이었어요. "이건 User", "이건 Book". 그런데 실제로는 "이건 User 또는 Guest", "상태는 loading 또는 success 또는 error" 같이 여러 가능성 중 하나인 경우가 많습니다. 다음 글인 "타입스크립트 기초 강좌 #4 Union / Literal / Narrowing"에서는 그런 경우를 다루는 강력한 도구들 — union 타입, literal 타입, narrowing을 살펴보겠습니다.