자바스크립트에서 객체는 그냥 아무 속성이나 담는 자루였다. { name: "hong", age: 20 } 같은 걸 만들어 놓고, 나중에 이 객체에 무슨 속성이 있었는지는 순전히 기억에 의존했다. 타입스크립트는 그 자루에 "이 객체는 이런 모양"이라는 설계도를 붙인다. 그 설계도를 만드는 도구가 인터페이스(interface)와 타입 별칭(type alias) 둘인데, 처음 배우면 이 둘이 뭐가 다른지부터 막힌다. 결론부터 말하면 객체 모양을 그릴 땐 거의 똑같고, 갈리는 지점이 몇 군데 있다. 아래 결과는 전부 실제 타입 검사기를 돌려 나온 그대로다.


인터페이스는 객체의 모양에 이름을 붙인다. interface 뒤에 이름을 적고 중괄호 안에 속성마다 타입을 나열하면, 그 이름이 곧 하나의 타입이 된다.


interface Point {
x: number;
y: number;
}
const a: Point = { x: 1, y: 2 }; // ok
const b: Point = { x: 1 };


error TS2741: Property 'y' is missing in type '{ x: number; }' but required in type 'Point'.


Pointxy를 둘 다 요구한다. b처럼 하나라도 빠지면 무엇이 빠졌는지 콕 집어 알려준다. 자바스크립트라면 b.yundefined인 걸 한참 뒤 화면이 깨진 다음에야 알았을 텐데, 여기선 객체를 만드는 그 줄에서 막힌다.


타입 별칭도 똑같은 객체를 그린다. type 뒤에 이름, 등호, 그리고 중괄호를 적는다. 인터페이스와 생김새만 조금 다를 뿐, 객체 모양을 정의하는 결과는 사실상 같다.


type Point = {
x: number;
y: number;
};
const a: Point = { x: 1, y: 2 }; // ok


등호가 붙는다는 것 말고는 위 인터페이스와 쓰임이 겹친다. 그래서 초보 입장에선 "그럼 아무거나 쓰면 되나" 싶은데, 대체로는 그렇다. 진짜 차이는 객체 바깥으로 나가면서 드러난다.


물음표는 있어도 되고 없어도 되는 속성이다. 속성 이름 뒤에 ?를 붙이면 그 속성은 선택이 된다. 없어도 통과하고, 있으면 타입을 지킨다.


interface User {
name: string;
age?: number; // 선택
}
const u: User = { name: "hong" }; // ok, age 없어도 됨
const v: User = { age: 20 };


error TS2741: Property 'name' is missing in type '{ age: number; }' but required in type 'User'.


age는 빠져도 되지만 name은 필수라, v처럼 name을 빼면 걸린다. 서버 응답에 어떤 필드는 올 수도 안 올 수도 있는 현실을 물음표 하나로 옮기는 셈이다. 이렇게 선택으로 잡아두면, 그 속성을 꺼내 쓸 때 타입스크립트가 "없을 수도 있으니 확인하라"고 미리 다그친다.


readonly는 한 번 정하면 못 바꾼다. 속성 앞에 readonly를 붙이면, 객체를 만들 때 값을 넣는 것까진 되지만 그 뒤 다시 대입하는 건 막힌다.


interface Point {
readonly x: number;
y: number;
}
const p: Point = { x: 1, y: 2 };
p.x = 9;


error TS2540: Cannot assign to 'x' because it is a read-only property.


x는 처음 1로 정해진 뒤 바꾸려 하자 거부당한다. 식별자(id)나 좌표의 기준값처럼 중간에 손대면 곤란한 값을 못박아 둘 때 쓴다. 자바스크립트의 const는 변수 자체의 재할당만 막지 속성 변경은 그냥 통과시켰는데, readonly는 그 구멍을 속성 단위로 메운다.


인터페이스는 extends로 물려받는다. 이미 있는 인터페이스를 바탕에 깔고 속성을 더 얹고 싶으면 extends를 쓴다. 상속받은 속성까지 전부 요구 사항에 들어간다.


interface Base { id: number }
interface Admin extends Base {
level: number;
}
const a: Admin = { id: 1 };


error TS2741: Property 'level' is missing in type '{ id: number; }' but required in type 'Admin'.


AdminBaseid에 자기 level까지 얹은 타입이라, level을 빼면 걸린다. 공통 필드를 한 인터페이스에 모아두고 여러 인터페이스가 그걸 물려받으면, 같은 속성을 매번 베껴 적지 않아도 된다.


타입 별칭은 &로 합쳐 넓힌다. 타입 별칭엔 extends가 없다. 대신 앰퍼샌드(&)로 여러 타입을 합쳐 같은 효과를 낸다. 결과는 extends와 사실상 같다.


type Base = { id: number };
type Admin = Base & { level: number };
const a: Admin = { id: 1 };


error TS2741: Property 'level' is missing ... 형태의 에러가 똑같이 난다. 그러니 "인터페이스는 extends, 타입 별칭은 &"라고만 짝지어 외워두면 확장은 어느 쪽으로도 된다.


타입 별칭은 객체 말고도 뭐든 이름 붙인다. 여기가 둘이 진짜 갈라지는 지점이다. 인터페이스는 오직 객체 모양만 그리지만, 타입 별칭은 유니온이든 튜플이든 기본 타입이든 이름을 붙일 수 있다.


type ID = string | number;
type Pair = [string, number];
const a: ID = 3; // ok
const b: Pair = ["x", 1]; // ok


이걸 인터페이스로 하려 들면 애초에 문법이 성립하지 않는다.


interface ID = string | number;


error TS1005: '{' expected.


인터페이스는 중괄호로 시작하는 객체 모양만 받으니, 유니온을 넣으려는 순간 파서가 튕겨낸다. "이거 아니면 저거" 같은 유니온이나 [string, number] 같은 튜플에 이름을 붙이려면 타입 별칭 말곤 방법이 없다.


인터페이스는 같은 이름으로 다시 열려 합쳐진다. 같은 이름의 인터페이스를 두 번 선언하면 에러가 아니라, 둘이 하나로 합쳐진다. 이걸 선언 병합(declaration merging)이라 부른다.


interface Box { w: number }
interface Box { h: number }
const c: Box = { w: 1 };


error TS2741: Property 'h' is missing in type '{ w: number; }' but required in type 'Box'.


두 번 적은 Box{ w, h }로 병합됐기에 h가 빠졌다고 걸린다. 반면 타입 별칭은 같은 이름을 두 번 쓰면 그냥 에러다.


type Animal = { name: string };
type Animal = { age: number };


error TS2300: Duplicate identifier 'Animal'.


이 병합이 별것 아닌 것 같아도, 남이 만든 라이브러리의 타입에 우리 프로젝트용 속성을 몰래 얹을 때 요긴하다. 나는 예전에 전역 window 객체에 우리 팀 전용 값을 붙여 쓰면서 타입 에러에 시달렸는데, 인터페이스 선언 병합으로 기존 Window에 속성 하나를 얹으니 깔끔히 풀렸다. 이건 타입 별칭으론 못 하는 일이다.


정리하면 객체 모양만 그릴 거라면 인터페이스든 타입 별칭이든 취향껏 쓰되 팀 안에서 하나로 통일하는 게 낫고, 유니온과 튜플과 기본 타입에 이름을 붙이는 순간부터는 타입 별칭이 답이다. 나는 "객체는 인터페이스, 조합은 타입"으로 선을 긋고 사는데, 규칙 하나 정해두면 매번 뭘 쓸지 고민하느라 멈칫할 일이 없다.