인터페이스는 객체의 모양, 즉 반드시 있어야 하는 속성과 각 속성의 타입에 이름을 붙입니다. 선언하고 나면 그 이름을 타입으로 쓰고, 컴파일러는 넘기거나 반환하거나 대입하는 모든 객체를 그것에 대해 검사합니다.
마지막 호출은 컴파일 오류 TS2741입니다. 인터페이스는 코드가 컴파일될 때 지워집니다. JavaScript 출력에는 User의 흔적이 없고, 런타임에 모양을 검사하는 것은 없습니다.
인터페이스 선언하기
문법은 interface 키워드, 이름(관례상 PascalCase), 그리고 멤버를 나열하는 본문입니다. 멤버는 세미콜론, 쉼표, 또는 줄바꿈만으로 구분할 수 있으며, 세미콜론이 흔한 스타일입니다.
interface Product {
sku: string; // required property
price: number;
tags: string[]; // array property
dimensions: { // nested object type
width: number;
height: number;
};
discount?: number; // optional property
readonly createdAt: Date; // cannot be reassigned
label(): string; // method
}
인터페이스는 값이 아니라 타입입니다. new로 인스턴스를 만들 수 없고, 기본값도 없으며, obj instanceof Product는 오류 TS2693('Product' only refers to a type, but is being used as a value here)입니다. 런타임에 모양을 확인하려면 타입 가드를 작성하세요.
구조적 타이핑과 초과 속성 검사
TypeScript는 이름이 아니라 모양을 비교합니다. 필요한 속성을 가진 객체라면 인터페이스로 선언되었든 아니든 그 인터페이스에 맞습니다. 추가 속성도 괜찮은데, 예외가 하나 있습니다. 인터페이스가 기대되는 자리에 바로 쓴 객체 리터럴은 초과 속성 검사를 받습니다. 거기서 알 수 없는 키는 거의 항상 오타이기 때문입니다.
이 오류(TS2353)가 User에 대한 { id: 1, name: "a", emial: "x" }를 잡아내며, 컴파일러는 Did you mean to write 'email'?(TS2561)이라고 제안하기까지 합니다.
선택적 속성과 readonly 속성
이름 뒤의 ?는 속성을 선택적으로 만듭니다. 객체가 그 속성을 빼도 되고, 읽으면 T | undefined가 됩니다. readonly는 객체가 만들어진 뒤 속성을 다시 대입하는 것을 막습니다.
여기서 두 가지 한계가 드러납니다. 첫째, readonly는 컴파일 시점에만 존재합니다. @ts-expect-error로 표시한 두 줄은 Run을 누르면 그대로 실행되어 성공하고, 마지막 줄들은 readonly 없이 타입이 지정된 참조를 통해 apiUrl을 바꿉니다. 둘째, 얕습니다. readonly hosts: string[]은 hosts를 다시 대입하는 것은 막지만 hosts.push(...)는 여전히 허용합니다. 그래서 배열 자체를 readonly string[]로 지정했습니다. readonly는 타입이 있는 코드에서 의도를 기록하고 강제할 뿐, 아무것도 동결하지 않습니다. Readonly<T> 유틸리티 타입은 기존 인터페이스의 모든 속성을 한꺼번에 readonly로 만듭니다.
메서드와 함수 속성
메서드는 메서드 시그니처 name(params): ReturnType으로 쓰거나, 함수를 담는 속성 name: (params) => ReturnType으로 쓸 수 있습니다. 호출하는 쪽은 둘을 같은 방식으로 씁니다.
차이는 미묘합니다. strictFunctionTypes(strict에 포함)에서 함수 타입 속성의 매개변수는 엄격하게 검사되지만, 메서드 시그니처는 더 느슨하게(이변적으로) 검사되므로 속성 형태가 실수를 몇 가지 더 잡아냅니다. 메서드 문법이 더 짧고 흔한 스타일이며, 둘 다 괜찮습니다.
인터페이스는 호출 시그니처나 생성 시그니처로 호출하거나 생성할 수 있는 것도 기술할 수 있습니다.
interface Formatter {
(value: number): string; // call signature: the object is a function
locale: string; // and it also has a property
}
interface PointConstructor {
new (x: number, y: number): { x: number; y: number }; // construct signature
}
인덱스 시그니처
속성 이름을 미리 알 수 없을 때는 인덱스 시그니처가 모든 속성을 한꺼번에 기술합니다. [key: string]: T는 "어떤 문자열 키든, 각각 T를 담는다"는 뜻입니다.
마지막 줄들이 함정을 보여 줍니다. 존재하지 않는 키를 읽어도 타입은 number | undefined가 아니라 number입니다. 컴파일러 옵션 noUncheckedIndexedAccess는 이런 모든 읽기에 | undefined를 더합니다.
이름 있는 속성은 인덱스 시그니처 옆에 둘 수 있지만 시그니처에 맞아야 합니다. interface Dict { [key: string]: number; name: string }는 오류 TS2411, Property 'name' of type 'string' is not assignable to 'string' index type 'number'입니다. 인덱스 타입을 넓히거나([key: string]: number | string) 동적인 부분을 별도 속성으로 옮기세요. 단순한 키/값 맵이라면 Record<string, number>가 한 줄로 같은 것을 말합니다.
인터페이스 확장하기
extends는 하나 이상의 기존 인터페이스로 새 인터페이스를 만듭니다. 자식은 부모의 모든 멤버와 자신의 멤버를 갖습니다.
interface Animal {
name: string;
}
interface Pet extends Animal {
owner: string;
}
interface Trained {
commands: string[];
}
interface ServiceDog extends Pet, Trained {
certifiedUntil: Date;
}
// ServiceDog requires: name, owner, commands, certifiedUntil
자식은 부모의 속성을 호환되는(더 좁은) 타입으로만 다시 선언할 수 있습니다. 부모가 kind: string이라고 하면 kind: "dog"처럼요. 규칙과 타입 별칭을 확장하는 방법은 extends 페이지에 있습니다.
클래스에서 인터페이스 구현하기
class X implements Shape는 인터페이스가 요구하는 모든 것이 클래스에 있는지 검사하라고 컴파일러에 요청합니다. 빠진 멤버는 클래스 선언에서 오류가 됩니다.
index.ts(7,7): error TS2420: Class 'Circle' incorrectly implements interface 'Shape'.
Property 'area' is missing in type 'Circle' but required in type 'Shape'.
area()를 추가하면 여러 클래스와 평범한 객체까지 모두 Shape로 쓸 수 있습니다.
implements는 검사일 뿐입니다. 클래스에 멤버를 추가하지 않고, 클래스 메서드의 매개변수 타입을 대신 지정해 주지도 않습니다. greet(name: string): string을 구현하는 클래스 안의 greet(name) {}는 여전히 오류 TS7006, Parameter 'name' implicitly has an 'any' type입니다. 클래스는 여러 인터페이스를 구현할 수 있습니다: class A implements B, C.
선언 병합
같은 스코프에서 같은 이름의 인터페이스를 두 번 선언하면 둘이 하나로 병합됩니다. 타입 별칭은 할 수 없는 일입니다(같은 이름의 두 번째 type은 중복 식별자 오류입니다).
interface Settings {
theme: string;
}
interface Settings {
fontSize: number;
}
// Settings now requires both properties
const s: Settings = { theme: "dark", fontSize: 14 };
애플리케이션 코드에서는 원하는 경우가 드물고, 뜻하지 않은 병합은 혼란스러울 수 있습니다. 실제 용도는 내가 소유하지 않은 타입에 멤버를 추가하는 것입니다. 라이브러리의 옵션이나 Window 같은 전역이 그렇습니다. 모듈 안에서는 선언을 declare global로 감싸세요.
declare global {
interface Window {
analytics: { track(event: string): void };
}
}
export {};
이렇게 하면 프로젝트 어디서든 window.analytics.track("signup")이 타입 검사를 통과합니다. 타입 정의 패키지도 같은 메커니즘에 기댑니다. 선언 파일을 참고하세요.
인터페이스 속성의 기본값
인터페이스는 타입을 기술하고 런타임에 지워지므로 기본값을 담을 수 없습니다. size?: "sm" | "md" = "md"는 오류 TS1246, An interface property cannot have an initializer입니다. 속성을 선택적으로 만들고 객체를 쓰는 곳에서 기본값을 채우세요.
구조 분해 기본값이 더 안전한 선택입니다. 명시적인 size: undefined를 포함해 값이 undefined일 때마다 적용되기 때문입니다. 펼치기 버전은 그 명시적인 undefined를 기본값 위에 복사하는데, 결과의 타입은 여전히 size가 항상 설정된 것처럼 지정됩니다. 타입으로 그 보장을 받고 싶다면 exactOptionalPropertyTypes를 켜세요. 그러면 선택적 size?: ...에 size: undefined를 넣는 것이 컴파일 오류가 됩니다. 객체에 동작도 필요하다면 필드를 초기화한 클래스가 또 다른 선택지입니다.
제네릭 인터페이스
인터페이스는 타입 매개변수를 받을 수 있어서, 선언 하나로 여러 페이로드 타입을 다룰 수 있습니다.
interface ApiResponse<T> {
ok: boolean;
data: T;
error?: string;
}
interface Page<T> {
items: T[];
nextCursor?: string;
}
interface User {
id: number;
name: string;
}
const res: ApiResponse<Page<User>> = {
ok: true,
data: { items: [{ id: 1, name: "Ada" }], nextCursor: "abc" },
};
ApiResponse<Page<User>>는 "데이터가 사용자 페이지인 응답"으로 읽힙니다. 표준 라이브러리는 이런 것으로 가득합니다. Array<T>, Promise<T>, Map<K, V> 모두 제네릭 인터페이스입니다.
인터페이스와 타입 별칭
type 별칭도 같은 객체 모양을 기술할 수 있고, 평범한 객체 타입이라면 둘은 바꿔 써도 됩니다. 병합은 인터페이스만 할 수 있고, 유니언, 튜플, 매핑된 타입이나 조건부 타입에 이름을 붙이는 것은 타입 별칭만 할 수 있습니다. TypeScript 핸드북의 경험칙은 type에만 있는 기능이 필요해질 때까지는 interface를 쓰라는 것입니다. interface와 type 비교 페이지에서 대부분의 사람이 놀라는 Record<string, ...> 차이를 포함해 전체 비교를 볼 수 있습니다.
자주 묻는 질문
TypeScript에서 인터페이스란 무엇인가요?
인터페이스는 객체 모양에 이름을 붙인 설명입니다. 속성 이름, 각 속성의 타입, 선택적이거나 readonly인 속성, 메서드를 기술합니다. 컴파일러는 그 인터페이스로 쓰이는 값이 그 모양을 갖는지 검사합니다. 인터페이스는 컴파일 시점에만 존재하며 JavaScript를 만들지 않습니다.
TypeScript 인터페이스에 기본값은 어떻게 설정하나요?
설정할 수 없습니다. 인터페이스는 값이 아니라 타입을 기술하므로 size: "md" = ...는 올바른 문법이 아닙니다. 속성을 선택적으로 만들고(size?: "sm" | "md") 객체를 쓰는 곳에서 기본값을 적용하세요. 보통 함수 매개변수에서 구조 분해 기본값을 씁니다: function render({ size = "md" }: Options). 기본값 객체를 펼치는 방법({ ...DEFAULTS, ...options })도 되지만, options에 명시적인 undefined가 있으면 기본값을 덮어씁니다.
런타임에 객체가 인터페이스를 구현하는지 어떻게 확인하나요?
인터페이스는 컴파일 중에 지워지므로 내장된 방법이 없습니다. obj instanceof User는 오류 TS2693('User' only refers to a type, but is being used as a value here)입니다. 속성을 확인하는 타입 가드 함수 function isUser(x: unknown): x is User { ... }를 작성하거나, 스키마 라이브러리로 검증하세요.
인터페이스가 여러 인터페이스를 확장할 수 있나요?
네. extends 뒤에 쉼표로 구분해 나열합니다: interface ServiceDog extends Pet, Trained { ... }. 새 인터페이스는 각 부모의 모든 멤버와 자신의 멤버를 갖습니다. 두 부모가 같은 속성을 호환되지 않는 타입으로 선언하면 그 선언은 오류입니다.
TypeScript에서 인터페이스와 클래스의 차이는 무엇인가요?
클래스는 런타임에 존재합니다. 생성자와 메서드 구현이 있고, new로 객체를 만듭니다. 인터페이스는 컴파일러를 위해 모양만 기술하고 JavaScript 출력에서 지워집니다. 클래스는 implements SomeInterface를 선언해 컴파일러가 맞는지 검사하게 할 수 있으며, 올바른 모양을 가진 평범한 객체도 인터페이스에 맞습니다.