TypeScript 인터페이스: 함수 타입, 인덱스 시그니처, 확장과 선언 병합, type alias와의 차이
이 글의 핵심
인터페이스로 객체 모양을 정의하는 기본부터 함수 타입과 인덱스 시그니처, extends 확장과 같은 이름 선언이 합쳐지는 병합 규칙, type alias와 무엇을 언제 쓸지 정리합니다.
들어가며
Interface는 객체가 가져야 할 모양(shape)을 적은 규격입니다(인터페이스: 타입이 따라야 할 필드·메서드 약속을 적어 둔 것). 값마다 붙는 명찰을 “이 필드들은 꼭 있어야 한다” 수준으로 맞추는 도구라고 보시면 됩니다.
Interface 선언과 선택적·읽기 전용 프로퍼티
선언
Interface는 객체가 가져야 할 프로퍼티와 타입을 정의합니다:
// User 인터페이스 선언
// 객체의 "계약(contract)"을 정의
interface User {
id: string; // 필수 프로퍼티: 문자열 타입의 id
name: string; // 필수 프로퍼티: 문자열 타입의 name
age: number; // 필수 프로퍼티: 숫자 타입의 age
email: string; // 필수 프로퍼티: 문자열 타입의 email
}
// ✅ 올바른 사용: 모든 프로퍼티 제공
const user: User = {
id: "U001",
name: "홍길동",
age: 25,
email: "[email protected]"
};
// TypeScript 컴파일러가 User 인터페이스와 비교하여 검증
// 모든 필수 프로퍼티가 있고 타입이 일치하므로 통과
// ❌ 컴파일 에러: 프로퍼티 누락
// const user2: User = {
// id: "U002",
// name: "김철수"
// // age와 email이 없음!
// };
// 에러 메시지:
// Type '{ id: string; name: string; }' is not assignable to type 'User'.
// Property 'age' is missing in type '{ id: string; name: string; }'
// ❌ 컴파일 에러: 타입 불일치
// const user3: User = {
// id: "U003",
// name: "이영희",
// age: "25", // string이지만 number 타입 필요
// email: "[email protected]"
// };
// 에러 메시지:
// Type 'string' is not assignable to type 'number'.
// ❌ 컴파일 에러: 추가 프로퍼티
// const user4: User = {
// id: "U004",
// name: "박민수",
// age: 30,
// email: "[email protected]",
// phone: "010-1234-5678" // User에 정의되지 않은 프로퍼티
// };
// 에러 메시지:
// Object literal may only specify known properties
Interface의 역할:
- 타입 체크: 컴파일 타임에 객체 구조 검증
- 자동 완성: IDE에서 프로퍼티 제안
- 문서화: 객체 구조를 명시적으로 표현
- 리팩토링: 타입 변경 시 모든 사용처 추적
여기서 꼭 짚고 넘어가야 할 점은 인터페이스가 컴파일 후 완전히 사라진다는 사실입니다. tsc가 만든 JavaScript에는 interface User의 흔적이 한 줄도 남지 않습니다. 그래서 fetch로 받은 JSON이 실제로 User 모양인지는 인터페이스가 보장해 주지 못합니다. const data: User = await res.json()은 “나는 이게 User라고 믿는다”는 선언일 뿐이고, 서버가 age를 문자열로 보내도 컴파일러는 알 길이 없습니다. 외부 입력 경계에서는 zod 같은 런타임 검증 라이브러리나 직접 작성한 타입 가드를 함께 써야 하는 이유가 여기에 있습니다.
마지막 예시의 “추가 프로퍼티” 에러(Object literal may only specify known properties)는 초과 프로퍼티 검사(excess property check)라는 별도 규칙입니다. TypeScript는 구조적 타이핑을 쓰기 때문에 원래는 필드가 더 많은 객체도 할당이 됩니다. 이 검사는 객체 리터럴을 바로 할당할 때만 동작하므로, 같은 객체를 먼저 다른 변수에 담았다가 넘기면 에러가 나지 않습니다.
const raw = { id: "U004", name: "박민수", age: 30, email: "[email protected]", phone: "010" };
const user4: User = raw; // ✅ 통과: 리터럴이 아니므로 초과 프로퍼티 검사 대상이 아님
처음 TypeScript를 쓸 때 흔히 헷갈리는 부분이 이것입니다. 리터럴로 쓰면 오타(emial)를 잡아 주던 컴파일러가, 변수를 거치면 아무 말도 하지 않습니다. “인터페이스에 없는 필드는 절대 못 들어온다”고 믿고 객체를 그대로 DB나 로그에 넘기면, 예상하지 못한 필드(비밀번호 해시 같은)가 함께 흘러가는 사고로 이어질 수 있습니다. 필요한 필드만 명시적으로 골라 새 객체를 만드는 습관이 더 안전합니다.
선택적 프로퍼티
? 기호로 선택적 프로퍼티를 정의할 수 있습니다:
interface User {
id: string; // 필수 프로퍼티
name: string; // 필수 프로퍼티
age?: number; // 선택적 프로퍼티 (있어도 되고 없어도 됨)
email?: string; // 선택적 프로퍼티
}
// age?: number는 age: number | undefined와 동일
// ✅ OK: 선택적 프로퍼티 없이 생성
const user1: User = {
id: "U001",
name: "홍길동"
// age와 email이 없지만 선택적이므로 에러 없음
};
// ✅ OK: 선택적 프로퍼티 포함
const user2: User = {
id: "U002",
name: "김철수",
age: 30,
email: "[email protected]"
};
// 선택적 프로퍼티 사용 시 주의
function printAge(user: User) {
// user.age는 number | undefined 타입
// 직접 사용하면 에러 가능성
// ❌ 위험: age가 undefined일 수 있음
// console.log(user.age.toFixed(0)); // 런타임 에러 가능
// ✅ 안전: 타입 가드 사용
if (user.age !== undefined) {
// 이 블록 안에서 user.age는 number 타입으로 좁혀짐
console.log(user.age.toFixed(0));
}
// ✅ 안전: Optional Chaining
console.log(user.age?.toFixed(0)); // undefined면 undefined 반환
// ✅ 안전: 기본값 제공
const age = user.age ?? 0; // undefined면 0 사용
console.log(age);
}
printAge(user1); // age 없음
printAge(user2); // age 있음
선택적 프로퍼티의 장점:
- 유연한 객체 구조 정의
- 부분 업데이트 시 유용
- API 응답처럼 일부 필드가 없을 수 있는 경우
코드 주석의 “age?: number는 age: number | undefined와 동일”은 엄밀히 말하면 절반만 맞습니다. age?: number는 키 자체를 생략해도 되지만, age: number | undefined는 키를 반드시 적어야 하고 값으로 undefined를 넣는 것만 허용합니다. 즉 { id, name }은 전자에서는 통과하지만 후자에서는 Property 'age' is missing 에러가 납니다. 또 tsconfig에서 exactOptionalPropertyTypes를 켜면 age?: number에 undefined를 명시적으로 대입하는 것까지 막힙니다. "age" in user 같은 키 존재 검사와 값 검사를 구분해야 하는 코드에서는 이 차이가 실제로 버그를 가릅니다.
선택적 프로퍼티를 남발하는 것도 경계해야 합니다. 모든 필드에 ?를 붙이면 컴파일은 편해지지만, 사용하는 쪽 코드가 ?.와 ??로 뒤덮이고 “이 필드가 언제 있고 언제 없는지”라는 정보가 타입에서 사라집니다. 생성 시점과 저장 이후처럼 상태가 명확히 나뉜다면 NewUser와 SavedUser처럼 인터페이스를 둘로 나누는 편이 의도를 더 잘 드러냅니다.
읽기 전용 프로퍼티
readonly 키워드로 변경 불가능한 프로퍼티를 정의할 수 있습니다:
interface User {
readonly id: string; // 읽기 전용: 초기화 후 변경 불가
name: string; // 일반 프로퍼티: 변경 가능
age: number;
}
// 객체 생성 시 초기화
const user: User = {
id: "U001", // 초기화 ✅
name: "홍길동",
age: 25
};
// 일반 프로퍼티는 변경 가능
user.name = "김철수"; // ✅ OK
user.age = 30; // ✅ OK
// readonly 프로퍼티는 변경 불가
// user.id = "U002"; // ❌ 컴파일 에러
// 에러 메시지:
// Cannot assign to 'id' because it is a read-only property.
// 실전 예시: 데이터베이스 엔티티
interface Post {
readonly id: string; // DB에서 자동 생성된 ID
readonly createdAt: Date; // 생성 시간 (변경 불가)
title: string; // 제목 (수정 가능)
content: string; // 내용 (수정 가능)
updatedAt: Date; // 수정 시간 (수정 가능)
}
const post: Post = {
id: "POST001",
createdAt: new Date(),
title: "첫 게시글",
content: "내용",
updatedAt: new Date()
};
// 게시글 수정
post.title = "수정된 제목"; // ✅ OK
post.content = "수정된 내용"; // ✅ OK
post.updatedAt = new Date(); // ✅ OK
// post.id = "POST002"; // ❌ 에러: ID는 변경 불가
// post.createdAt = new Date(); // ❌ 에러: 생성 시간 변경 불가
readonly vs const:
readonly: 프로퍼티에 사용 (객체의 속성)const: 변수에 사용 (변수 자체)
const user: User = { id: "U001", name: "홍길동", age: 25 };
// const: user 변수 자체를 재할당 불가
// user = { ... }; // ❌ 에러
// readonly: user.id 프로퍼티를 변경 불가
// user.id = "U002"; // ❌ 에러
readonly에는 두 가지 한계가 있습니다. 첫째, 얕습니다. readonly tags: string[]로 선언해도 post.tags = []만 막힐 뿐 post.tags.push("x")는 그대로 통과합니다. 배열 내용까지 막으려면 readonly tags: readonly string[](또는 ReadonlyArray<string>)로 써야 합니다. 둘째, 컴파일 타임 전용입니다. 런타임에는 평범한 쓰기 가능한 프로퍼티라서, 타입을 any로 우회하거나 JavaScript 코드에서 접근하면 얼마든지 바뀝니다. 런타임 불변성이 정말 필요하면 Object.freeze를 함께 써야 합니다.
또 하나 헷갈리는 부분은 readonly 프로퍼티를 가진 타입이 일반 타입으로 할당 가능하다는 점입니다. const u: { id: string } = readonlyUser;는 에러 없이 통과하고, 그 뒤 u.id = "X"로 원본을 바꿀 수 있습니다. readonly는 “이 참조를 통해서는 바꾸지 않겠다”는 약속이지, 객체 자체를 잠그는 장치가 아니라고 이해하는 편이 정확합니다.
메서드·함수·생성자 타입 표현
메서드
interface Calculator {
add(a: number, b: number): number;
subtract(a: number, b: number): number;
multiply?(a: number, b: number): number; // 선택적
}
const calc: Calculator = {
add(a, b) {
return a + b;
},
subtract(a, b) {
return a - b;
}
};
console.log(calc.add(10, 5));
console.log(calc.subtract(10, 5));
add(a, b)처럼 구현부에 파라미터 타입을 적지 않아도 되는 이유는 문맥적 타이핑(contextual typing) 덕분입니다. calc가 Calculator로 선언되어 있으니 컴파일러가 a, b를 number로 추론합니다. multiply는 선택적 메서드라 구현하지 않아도 되지만, 호출하는 쪽에서는 calc.multiply?.(2, 3)처럼 존재 여부를 확인해야 합니다.
메서드를 add(a: number, b: number): number;(메서드 문법)로 쓰느냐 add: (a: number, b: number) => number;(프로퍼티 문법)로 쓰느냐에도 차이가 있습니다. strictFunctionTypes 옵션을 켜도 메서드 문법은 파라미터를 이변적(bivariant)으로 검사하기 때문에 더 느슨합니다. 콜백 파라미터 타입을 엄격하게 검사받고 싶다면 프로퍼티 문법이 안전합니다. 반대로 DOM 이벤트 타입처럼 기존 라이브러리와 호환해야 할 때는 메서드 문법의 느슨함이 오히려 도움이 되기도 합니다.
함수 타입
// 타입 정의
interface MathOperation {
(a: number, b: number): number;
}
const add: MathOperation = (a, b) => a + b;
const multiply: MathOperation = (a, b) => a * b;
console.log(add(10, 5));
console.log(multiply(10, 5));
이렇게 괄호만 있는 멤버를 호출 시그니처(call signature)라고 부릅니다. 단순한 함수 타입이라면 type MathOperation = (a: number, b: number) => number가 더 짧고 읽기 쉬워서 실무에서는 type alias를 더 자주 봅니다. 호출 시그니처 인터페이스가 빛나는 경우는 함수이면서 프로퍼티도 가진 값을 표현할 때입니다. 예를 들어 interface Logger { (msg: string): void; level: "info" | "debug"; }처럼 jQuery의 $나 Express의 app처럼 호출도 되고 필드도 있는 객체를 타입으로 옮길 수 있습니다.
생성자 타입
interface ClockConstructor {
new (hour: number, minute: number): ClockInterface;
}
interface ClockInterface {
tick(): void;
}
class DigitalClock implements ClockInterface {
constructor(h: number, m: number) {}
tick() {
console.log("beep beep");
}
}
function createClock(
ctor: ClockConstructor,
hour: number,
minute: number
): ClockInterface {
return new ctor(hour, minute);
}
const clock = createClock(DigitalClock, 12, 17);
clock.tick();
생성자 타입을 인터페이스 두 개로 나눈 데는 이유가 있습니다. 클래스에는 인스턴스 쪽 타입(메서드, 필드)과 정적 쪽 타입(생성자, static 멤버)이 따로 있는데, implements는 인스턴스 쪽만 검사합니다. 그래서 class DigitalClock implements ClockConstructor라고 쓰면 “DigitalClock provides no match for the signature new (hour: number, minute: number): ClockInterface” 같은 에러가 납니다. 생성자 모양은 이 예제의 createClock처럼 클래스 자체를 값으로 받는 자리에서 ClockConstructor 타입으로 검사해야 합니다. 플러그인 레지스트리나 DI 컨테이너처럼 “클래스를 받아서 나중에 인스턴스를 만드는” 코드에서 자주 쓰는 패턴입니다.
인덱스 시그니처
문자열 인덱스
interface StringMap {
[key: string]: string;
}
const colors: StringMap = {
red: "#FF0000",
green: "#00FF00",
blue: "#0000FF"
};
console.log(colors["red"]);
console.log(colors.green);
인덱스 시그니처는 키 이름을 미리 알 수 없는 사전형 객체에 씁니다. 편리한 대신 대가가 있습니다. colors.purple처럼 존재하지 않는 키를 읽어도 타입은 string으로 나옵니다. 실제 값은 undefined인데 타입이 거짓말을 하는 셈이라, colors.purple.toUpperCase()가 컴파일을 통과하고 런타임에 TypeError: Cannot read properties of undefined로 터집니다. tsconfig의 noUncheckedIndexedAccess를 켜면 인덱스 접근 결과가 string | undefined로 바뀌어 이 문제를 잡을 수 있습니다.
키가 정해진 집합이라면 인덱스 시그니처보다 Record<"red" | "green" | "blue", string>이 낫습니다. 키 누락도, 오타도 컴파일러가 잡아 줍니다. 키가 동적으로 추가·삭제되고 순회 순서가 중요하다면 일반 객체 대신 Map<string, string>을 쓰는 것도 고려할 만합니다.
숫자 인덱스
// 타입 정의
interface NumberArray {
[index: number]: string;
}
const fruits: NumberArray = ["사과", "바나나", "오렌지"];
console.log(fruits[0]);
console.log(fruits[1]);
숫자 인덱스 시그니처를 가진 NumberArray에 배열을 넣을 수는 있지만, 이 타입에는 length, map, push 같은 배열 메서드가 없습니다. fruits.length를 쓰면 Property 'length' does not exist on type 'NumberArray' 에러가 납니다. 진짜 배열이 필요하면 string[]이나 readonly string[]을 쓰고, 숫자 인덱스 시그니처는 arguments나 DOM의 NodeList 같은 유사 배열(array-like) 객체를 표현할 때 주로 씁니다.
혼합 사용
interface Dictionary {
[key: string]: string | number;
length: number;
}
const dict: Dictionary = {
name: "홍길동",
age: 25,
length: 2
};
혼합 사용에는 규칙이 하나 있습니다. 명시적으로 선언한 프로퍼티의 타입은 인덱스 시그니처 값 타입에 포함되어야 합니다. 위 예제에서 length: number가 허용되는 것은 인덱스 시그니처가 string | number이기 때문입니다. 인덱스 시그니처를 [key: string]: string으로 바꾸면 Property 'length' of type 'number' is not assignable to 'string' index type 'string' 에러가 납니다. obj["length"]로 접근해도 인덱스 시그니처 규칙과 모순이 없어야 하기 때문입니다. 이 제약 때문에 “고정 필드 몇 개 + 자유 필드”를 한 인터페이스에 담으려다 인덱스 값 타입이 점점 넓어져 결국 any에 가까워지는 경우가 많은데, 그럴 땐 { meta: {...}; extra: Record<string, string> }처럼 자유 필드를 별도 프로퍼티로 분리하는 편이 깔끔합니다.
extends로 Interface 확장
extends
interface Person {
name: string;
age: number;
}
interface Employee extends Person {
employeeId: string;
department: string;
}
const employee: Employee = {
name: "홍길동",
age: 30,
employeeId: "E001",
department: "개발팀"
};
다중 확장
interface Timestamped {
createdAt: Date;
updatedAt: Date;
}
interface Identifiable {
id: string;
}
interface User extends Identifiable, Timestamped {
name: string;
email: string;
}
const user: User = {
id: "U001",
name: "홍길동",
email: "[email protected]",
createdAt: new Date(),
updatedAt: new Date()
};
extends는 단순히 필드를 복사해 붙이는 것이 아니라 호환성 검사를 함께 합니다. 자식 인터페이스에서 부모의 프로퍼티를 다시 선언할 수는 있지만, 부모 타입에 할당 가능한 더 좁은 타입이어야 합니다. interface A { id: string | number }를 확장해 id: string으로 좁히는 것은 되지만, id: boolean으로 바꾸면 Interface 'B' incorrectly extends interface 'A' 에러가 납니다. 다중 확장에서 두 부모가 같은 이름의 프로퍼티를 서로 다른 타입으로 갖고 있을 때도 같은 에러가 납니다.
type alias의 &(교차 타입)는 이런 충돌을 에러로 알려 주지 않고 조용히 never로 만듭니다. { id: string } & { id: number }의 id는 never가 되어, 그 타입의 값을 만들려는 순간에야 이상한 에러를 보게 됩니다. 객체 모양을 계층적으로 쌓는 용도라면 충돌을 선언 지점에서 바로 잡아 주는 extends가 디버깅이 훨씬 쉽습니다. TypeScript 팀도 컴파일 성능 면에서 인터페이스 확장이 교차 타입보다 유리하다고 안내하고 있습니다(인터페이스 관계는 캐시되지만 교차 타입은 매번 다시 계산됨).
Declaration Merging으로 Interface 병합
Declaration Merging
interface User {
name: string;
}
interface User {
age: number;
}
const user: User = {
name: "홍길동",
age: 25
};
같은 스코프에 같은 이름의 인터페이스가 여러 번 선언되면 컴파일러가 멤버를 하나로 합칩니다. 이것을 선언 병합(declaration merging)이라고 합니다. 같은 이름의 프로퍼티가 양쪽에 있으면 타입이 똑같아야 하고, 다르면 Subsequent property declarations must have the same type 에러가 납니다. 메서드는 오버로드로 합쳐지는데, 나중에 선언된 인터페이스의 오버로드가 앞쪽에 배치된다는 점도 알아 두면 오버로드 해석 순서 문제를 추적할 때 도움이 됩니다.
병합은 양날의 검입니다. 라이브러리 타입을 확장할 때는 필수 기능이지만, 애플리케이션 코드에서 우연히 같은 이름을 쓰면 에러 없이 두 정의가 합쳐져 버립니다. 특히 전역 스크립트 파일(모듈이 아닌 파일)에 interface User를 선언하면 다른 전역 파일의 User와 합쳐질 수 있습니다. 반면 type alias는 같은 이름으로 두 번 선언하면 Duplicate identifier 에러가 나므로, “확장을 허용하지 않겠다”는 의도를 표현할 때 type alias를 고르는 팀도 있습니다.
라이브러리 확장
interface Window {
myCustomProperty: string;
}
window.myCustomProperty = "Hello!";
console.log(window.myCustomProperty);
위 코드는 import/export가 없는 전역 스크립트 파일에서만 그대로 동작합니다. 요즘 프로젝트 파일은 대부분 모듈이므로 실제로는 다음처럼 declare global로 감싸야 합니다.
// global.d.ts 또는 아무 모듈 파일
export {};
declare global {
interface Window {
myCustomProperty: string;
}
}
제가 이 패턴에서 가장 자주 본 실수는 모듈 파일에 interface Window를 그냥 선언해 두고 “선언했는데 왜 Property 'myCustomProperty' does not exist on type 'Window & typeof globalThis'가 나지?” 하고 헤매는 경우입니다. 모듈 안의 선언은 그 모듈의 지역 타입이 되어 전역 Window와 병합되지 않습니다. 같은 원리로 Express의 Request에 user 필드를 추가할 때도 declare module "express-serve-static-core" { interface Request { user?: User } }처럼 원래 선언이 있는 모듈 이름으로 모듈 보강(module augmentation)을 해야 합니다. 그리고 이런 .d.ts 파일이 tsconfig의 include 범위에 들어가 있는지도 꼭 확인해야 합니다.
클래스에서 implements로 구현
implements
interface Animal {
name: string;
makeSound(): void;
}
class Dog implements Animal {
name: string;
constructor(name: string) {
this.name = name;
}
makeSound() {
console.log("멍멍!");
}
}
const dog = new Dog("바둑이");
dog.makeSound();
implements는 검사만 할 뿐 아무것도 물려주지 않습니다. 인터페이스에 name: string이 있어도 클래스에서 name 필드를 직접 선언해야 하고, 메서드 파라미터 타입도 인터페이스에서 추론해 주지 않습니다. 예를 들어 인터페이스에 makeSound(times: number): void가 있어도 클래스의 makeSound(times)에서 times는 number가 아니라 암묵적 any가 됩니다(noImplicitAny에서는 에러). 구현을 공유하고 싶다면 추상 클래스(abstract class)와 extends를 쓰는 것이 맞습니다.
그렇다면 왜 굳이 implements를 쓸까요? TypeScript는 구조적 타이핑이라 implements가 없어도 Dog 인스턴스는 Animal 자리에 들어갑니다. implements의 진짜 가치는 에러 위치입니다. 인터페이스에 메서드가 추가되었을 때, implements가 있으면 클래스 선언부에서 바로 Class 'Dog' incorrectly implements interface 'Animal' 에러가 나고, 없으면 그 클래스를 Animal로 넘기는 먼 사용처에서야 에러가 납니다.
다중 구현
interface Flyable {
fly(): void;
}
interface Swimmable {
swim(): void;
}
class Duck implements Flyable, Swimmable {
fly() {
console.log("날아간다!");
}
swim() {
console.log("수영한다!");
}
}
const duck = new Duck();
duck.fly();
duck.swim();
Interface와 Type Alias 중 무엇을 쓸까
비교
| 특징 | Interface | Type Alias |
|---|---|---|
| 객체 타입 | ✅ | ✅ |
| Union/Intersection | ❌ | ✅ |
| 확장 | extends | & |
| 병합 | ✅ | ❌ |
| 원시 타입 | ❌ | ✅ |
예제
// Interface
interface User {
name: string;
}
interface User {
age: number;
}
// Type Alias
type Person = {
name: string;
};
// Type Alias는 Union 가능
type ID = string | number;
type Status = "active" | "inactive";
// Intersection
type Employee = Person & {
employeeId: string;
};
표에서 “원시 타입 ❌“은 인터페이스가 string | number 같은 유니온이나 원시 타입 별칭을 표현할 수 없다는 뜻입니다. 반대로 매핑된 타입({ [K in keyof T]: ... }), 조건부 타입, 튜플 같은 것도 type alias로만 정의할 수 있습니다. 객체 모양만 놓고 보면 둘은 거의 대부분 서로 바꿔 쓸 수 있습니다.
실무에서 쓰는 판단 기준은 대체로 다음과 같습니다.
- 공개 API나 라이브러리가 노출하는 객체 타입: 사용자가 선언 병합으로 확장할 수 있도록 interface.
@types/*패키지들이 interface를 쓰는 이유입니다. - 유니온, 튜플, 함수 타입, 유틸리티 타입 조합: type alias만 가능하거나 훨씬 자연스럽습니다.
- 앱 내부 객체 타입: 어느 쪽이든 무방하므로 팀 규칙으로 하나를 정하는 것이 가장 중요합니다.
typescript-eslint의consistent-type-definitions규칙으로 강제할 수 있습니다.
한 가지 미묘한 차이도 있습니다. interface로 선언한 타입은 암묵적 인덱스 시그니처가 없다고 취급되어 Record<string, unknown> 자리에 넣으면 Index signature for type 'string' is missing in type 'User' 에러가 납니다. 같은 모양을 type alias로 선언하면 통과합니다. interface는 나중에 병합으로 멤버가 늘어날 수 있으니 컴파일러가 보수적으로 판단하는 것입니다. 객체를 로깅 함수나 직렬화 유틸에 넘기다 이 에러를 만나면 원인이 이것입니다.
예제: API 응답·폼 검증·이벤트 핸들러
예제 1: API 응답
interface ApiResponse<T> {
success: boolean;
data: T;
error?: string;
timestamp: Date;
}
interface User {
id: string;
name: string;
email: string;
}
async function fetchUser(id: string): Promise<ApiResponse<User>> {
try {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return {
success: true,
data,
timestamp: new Date()
};
} catch (error) {
return {
success: false,
data: null as any,
error: "에러 발생",
timestamp: new Date()
};
}
}
이 예제는 제네릭 인터페이스의 모양을 보여 주기엔 좋지만, 실제 코드로 쓰기엔 두 군데가 위험합니다. 첫째, response.json()은 Promise<any>를 돌려주므로 data는 검증 없이 User로 취급됩니다. 또 fetch는 404나 500 응답에서도 예외를 던지지 않기 때문에 response.ok 확인이 빠져 있으면 에러 응답 본문이 success: true로 포장되어 나갑니다. 둘째, data: null as any는 실패했을 때도 data가 User라고 거짓말을 합니다. 호출하는 쪽에서 success를 확인하지 않고 res.data.name에 접근해도 컴파일러가 막아 주지 못합니다.
이런 경우에는 판별 유니온(discriminated union)이 더 정확합니다.
type ApiResult<T> =
| { success: true; data: T; timestamp: Date }
| { success: false; error: string; timestamp: Date };
이렇게 하면 if (res.success) 블록 안에서만 res.data에 접근할 수 있고, 실패 분기에서는 error만 보입니다. 유니온이 필요하므로 이 부분은 interface가 아닌 type alias로 써야 한다는 점도 앞의 비교표와 연결됩니다.
예제 2: 폼 검증
interface FormField {
value: string;
error: string | null;
touched: boolean;
}
interface LoginForm {
email: FormField;
password: FormField;
}
const form: LoginForm = {
email: {
value: "",
error: null,
touched: false
},
password: {
value: "",
error: null,
touched: false
}
};
function validateEmail(email: string): string | null {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email) ? null : "올바른 이메일 형식이 아닙니다";
}
form.email.value = "[email protected]";
form.email.error = validateEmail(form.email.value);
form.email.touched = true;
FormField를 한 번 정의하고 LoginForm에서 재사용한 구조가 핵심입니다. 필드가 늘어나도 각 필드의 상태 모양(value, error, touched)은 한 곳에서 관리됩니다. 폼 필드가 많아지면 type FormOf<K extends string> = Record<K, FormField>처럼 키 목록에서 폼 타입을 만들어 내는 방식으로 발전시킬 수 있습니다. error: string | null을 error?: string 대신 쓴 것도 의도적인 선택입니다. “아직 검사 안 함”과 “검사했는데 에러 없음”을 touched와 null로 명시적으로 구분하려는 것입니다.
예제 3: 이벤트 핸들러
interface ClickEvent {
x: number;
y: number;
button: "left" | "right";
}
interface EventHandler<T> {
(event: T): void;
}
const handleClick: EventHandler<ClickEvent> = (event) => {
console.log(`클릭: (${event.x}, ${event.y}), 버튼: ${event.button}`);
};
handleClick({ x: 100, y: 200, button: "left" });
EventHandler<T>는 호출 시그니처와 제네릭을 결합한 형태입니다. handleClick의 event에 타입을 적지 않아도 ClickEvent로 추론되는 것은 앞의 문맥적 타이핑 덕분입니다. button: "left" | "right" 같은 리터럴 유니온은 한 가지 함정이 있는데, const e = { x: 1, y: 2, button: "left" }처럼 변수에 먼저 담으면 button이 string으로 넓혀져서 handleClick(e)가 Type 'string' is not assignable to type '"left" | "right"' 에러를 냅니다. 이때는 const e: ClickEvent = ...로 타입을 명시하거나 as const, satisfies ClickEvent를 붙여 리터럴 타입을 유지하면 됩니다.
Interface 요약
- Interface: 객체 구조 정의
- 선택적 프로퍼티:
? - 읽기 전용:
readonly - 확장:
extends(다중 가능) - 구현:
implements(다중 가능) - 병합: Declaration Merging
Interface 사용 시기
- 객체 타입 정의
- 클래스 구조 강제
- 라이브러리 확장
- API 응답 타입
다음 단계
같이 보면 좋은 글
- TypeScript 고급 타입 | Union, Intersection, Literal 타입
- TypeScript 제네릭
- TypeScript 유틸리티 타입 | Partial, Pick, Omit, Record
자주 묻는 질문 (FAQ)
Q. 전역 Window에 프로퍼티를 추가했는데 타입 에러가 계속 나는 이유는 무엇인가요?
A. interface Window를 다시 선언하면 선언 병합으로 기존 Window 타입에 프로퍼티가 추가되지만, 그 파일에 import나 export가 있어 모듈로 취급되면 선언이 모듈 범위에 갇혀 전역 Window와 병합되지 않습니다. 이때는 declare global 블록 안에 interface Window를 선언해야 합니다. 같은 이름의 interface가 자동으로 합쳐지는 성질은 라이브러리 타입 확장에 유용하지만, 의도치 않은 이름 충돌도 조용히 병합되므로 전역 선언은 한 파일에 모아 두는 편이 좋습니다.