객체와 인터페이스
객체 리터럴 타입과 interface로 필수와 선택, 읽기 전용 속성, 호출 시그니처, 인덱스 시그니처를 선언하고, 구조적 할당과 초과 속성 검사의 차이와 인터페이스의 확장을 확인합니다.
실제 프로그램이 다루는 데이터는 대부분 여러 값을 묶은 객체입니다. 회원 한 명은 이름과 나이, 학생인지의 여부를 함께 가지고, 이 값들은 이름이 붙은 속성(property)으로 한 객체에 담깁니다. 앞 글의 object는 "원시 값이 아니다"라는 사실만 알려 주므로, 속성을 쓰려면 객체의 모양을 더 구체적으로 적어야 합니다.
이 글은 객체의 모양을 직접 적는 객체 리터럴 타입에서 시작해, 같은 모양에 이름을 붙여 다시 쓰는 인터페이스(interface)와 그 확장까지 차례로 봅니다. 예제는 앞 글처럼 ts-first 프로젝트에 objects.ts를 만들고 include를 그 파일로 바꾸어 확인합니다.
TYPE / RUNTIME / CONTRACT
필요한 구조와 새 리터럴의 추가 검사를 구분한다
객체 타입은 필요한 속성과 호환되는 타입을 검사합니다. 추가 속성이 있는 중간 변수를 모두 거절하는 닫힌 레코드 규칙과, 새 객체 리터럴의 초과 속성 검사는 다릅니다.
ACTUAL SOURCE / REQUIRED
isStudent 누락과 age 문자열을 진단
본문 person에는 name:string,age:number,isStudent:boolean이 필요합니다. isStudent를 빼거나 age를 문자열로 넣으면 맞지 않습니다.
ACTUAL SOURCE / ACCESS
person.address 접근은 별도 진단
타입에 알려지지 않은 address를 접근하는 예제는 오류입니다. 객체에 추가 속성이 존재할 수 있는 것과 해당 타입 경로로 그 속성을 읽을 수 있는 것은 다른 질문입니다.
FRESH LITERAL / ADDITIONAL CHECK
직접 새 리터럴의 오타를 찾는다
대상 객체 타입에 새 리터럴을 직접 할당하면 초과 속성 검사가 추가될 수 있습니다. 인덱스 시그니처처럼 추가 키를 허용하는 계약도 함께 고려합니다.
STRUCTURAL / VARIABLE
중간 변수는 필요한 구조를 만족하는가
필요한 필드가 있는 중간 변수는 추가 필드가 있어도 일반 구조적 할당이 가능합니다. 초과 속성 검사만 피했다고 필수 필드 누락이나 잘못된 타입이 허용되는 것은 아닙니다.
필수 속성과 타입 호환을 확인합니다. 새 리터럴의 초과 속성 검사, 중간 변수의 구조적 할당, 알려지지 않은 속성 접근의 진단을 구분합니다.
객체 리터럴 타입
객체의 모양은 중괄호 안에 속성의 이름과 타입을 나열해 적습니다. 이것을 객체 리터럴 타입(object literal type)이라고 하며, 속성 사이는 세미콜론이나 쉼표로 구분합니다.
let person: {
name: string;
age: number;
isStudent: boolean;
};
person = {
name: "김민준",
age: 25,
isStudent: true,
};
console.log(person.name, person.age);김민준 25컴파일러는 이 타입에 맞추어 세 가지를 검사합니다. 타입에 없는 속성을 쓰는지, 필요한 속성이 빠졌는지, 속성의 값이 맞는 타입인지입니다. 다음 세 줄은 각각 이 세 가지에 걸립니다.
person.address = "서울";
person = { name: "박서준", age: 30 };
person = { name: "최유리", age: "스물셋", isStudent: false };error TS2339: Property 'address' does not exist on type '{ name: string; age: number; isStudent: boolean; }'.
error TS2741: Property 'isStudent' is missing in type '{ name: string; age: number; }' but required in type '{ name: string; age: number; isStudent: boolean; }'.
error TS2322: Type 'string' is not assignable to type 'number'.TS2339는 없는 속성에 접근했다는 진단이고, TS2741은 필요한 속성이 빠졌다는 진단입니다. 마지막 줄의 TS2322는 객체 전체가 아니라 age 속성의 값 "스물셋"에서 보고되므로, 어느 속성이 틀렸는지 바로 찾을 수 있습니다.
구조적 할당과 초과 속성 검사
타입스크립트는 객체의 타입을 이름이 아니라 구조로 비교합니다. 이것을 구조적 타이핑(structural typing)이라고 하며, 필요한 속성을 모두 맞는 타입으로 가진 객체라면 속성이 더 있어도 그 타입에 할당할 수 있습니다.
const received = { name: "민준", age: 25, address: "서울" };
const personView: { name: string; age: number } = received;
console.log(personView.name);
console.log(received.address);민준
서울received는 name과 age를 모두 가지므로 personView에 할당됩니다. 이때 address가 실제 객체에서 지워지는 것은 아니어서, 두 변수는 같은 객체를 가리키고 received.address로는 여전히 값을 읽을 수 있습니다. 다만 personView의 타입에는 address가 없으므로 personView.address는 TS2339로 진단됩니다.
그런데 같은 객체를 변수를 거치지 않고 리터럴로 바로 넣으면 진단이 나옵니다. 새로 만든 객체 리터럴에는 초과 속성 검사(excess property check)가 한 번 더 적용되기 때문입니다.
const directView: { name: string; age: number } = { name: "민준", age: 25, address: "서울" };error TS2353: Object literal may only specify known properties, and 'address' does not exist in type '{ name: string; age: number; }'.새 리터럴의 추가 속성은 그 타입으로는 다시 읽을 방법이 없으므로, 대개 속성 이름을 잘못 쓴 실수입니다. 그래서 컴파일러는 새 리터럴에서만 추가 속성을 알려 주고, 이미 만들어진 객체를 옮기는 할당은 구조만 검사합니다. 위 그림의 세 단계처럼 필요한 속성이 있는지, 속성의 타입이 맞는지, 새 리터럴의 추가 속성이 있는지를 따로 확인합니다.
선택적 속성
모든 객체가 같은 속성을 다 가지지는 않습니다. 속성 이름 뒤에 물음표를 붙이면 선택적 속성(optional property)이 되어, 그 속성이 없어도 되고 있다면 정한 타입이어야 합니다.
let car: {
brand: string;
model: string;
year?: number;
};
car = { brand: "Hyundai", model: "Sonata", year: 2023 };
car = { brand: "Kia", model: "K5" };
console.log(car.year);undefined두 번째 객체에는 year가 없으므로 마지막 줄은 undefined를 출력합니다. 그래서 car.year를 읽은 값의 타입은 number | undefined이고, 숫자의 메서드를 쓰려면 먼저 undefined가 아닌지 확인해야 합니다.
year?: number에 year: undefined처럼 undefined를 직접 넣는 것은 기본 설정에서 허용됩니다. exactOptionalPropertyTypes를 켜면 "속성이 없음"과 "값이 undefined인 속성이 있음"을 구분해, 직접 넣는 쪽을 진단합니다. 두 경우를 모두 허용하려면 year?: number | undefined처럼 적으며, 자세한 조건은 공식 exactOptionalPropertyTypes 설명에 있습니다.
더 깊이: 모든 속성이 선택적인 약한 타입
구조적 타이핑만 따르면, 속성이 모두 선택적인 타입에는 거의 어떤 객체든 들어갈 수 있습니다. 요구하는 속성이 하나도 없으니 어떤 객체도 조건을 어기지 않기 때문입니다. 그래서 data?, timeout?, maxRetries?만 가진 옵션 타입에 payload와 retryOnFail을 담은 객체를 넘기는, 이름을 통째로 잘못 쓴 실수가 그대로 통과할 수 있었습니다.
TypeScript 2.4는 이런 타입을 약한 타입(weak type)이라 부르고, 약한 타입과 겹치는 속성이 하나도 없는 값을 넣으면 오류로 처리하기 시작했습니다(TypeScript 2.4 릴리스 노트). 초과 속성 검사와 달리 이 검사는 새 리터럴뿐 아니라 변수에 담아 둔 객체를 넘길 때도 적용됩니다. 겹치는 속성이 하나라도 있으면 이 검사는 통과하므로, 약한 타입에 대한 검사는 '완전히 다른 모양'만 걸러 낸다고 보면 됩니다. 정말로 그런 객체를 넣어야 한다면 릴리스 노트가 드는 대로 속성을 타입에 선언하거나, 인덱스 시그니처를 더하거나, 타입 단언을 씁니다.
읽기 전용 속성
속성 이름 앞에 readonly를 붙이면, 그 타입을 통해서는 속성에 새 값을 넣지 못합니다. 회원의 ID처럼 만든 뒤 바뀌면 안 되는 값에 씁니다.
let userProfile: {
readonly id: string;
name: string;
};
userProfile = { id: "user_abc_123", name: "이수진" };
userProfile.name = "이수진님";
console.log(userProfile.id, userProfile.name);user_abc_123 이수진님name은 바꿀 수 있지만, userProfile.id = "new_id";는 Cannot assign to 'id' because it is a read-only property.라는 TS2540으로 진단됩니다. readonly는 타입 검사에서만 대입을 막는 표시이므로, 실행 중의 객체를 얼리지 않고 속성 안의 중첩 객체까지 막지도 않습니다. 실행 중에도 바뀌지 않게 하려면 Object.freeze 같은 별도의 방법을 함께 씁니다.
인터페이스
같은 모양의 객체 타입을 여러 곳에서 쓰면, 매번 객체 리터럴 타입을 다시 적어야 합니다. 인터페이스는 객체의 모양에 이름을 붙이는 선언이어서, 한 번 정의한 모양을 이름으로 다시 쓸 수 있습니다.
interface Person {
name: string;
age: number;
isStudent?: boolean;
readonly nationalId: string;
}
const student: Person = {
name: "김영희",
age: 22,
isStudent: true,
nationalId: "ID-2201",
};
const teacher: Person = {
name: "박선생",
age: 40,
nationalId: "ID-4001",
};
console.log(student.name, teacher.isStudent);김영희 undefined인터페이스에도 객체 리터럴 타입처럼 선택적 속성과 읽기 전용 속성을 쓸 수 있습니다. teacher는 선택적 속성 isStudent를 생략했으므로 그 값은 undefined이고, student.nationalId = "새 ID";는 읽기 전용 속성이어서 TS2540으로 진단됩니다. 인터페이스는 타입의 선언일 뿐이어서, 컴파일한 자바스크립트에는 아무 코드도 남기지 않습니다.
모양에 이름을 붙이면 얻는 것은 다음과 같습니다.
- 같은 모양을 여러 변수와 함수에서 이름 하나로 다시 쓸 수 있습니다.
- 진단과 편집기의 표시에 긴 객체 타입 대신
Person같은 이름이 나와 읽기 쉽습니다. - 팀이 함께 쓰는 데이터의 모양을 한곳에 정의해 공유할 수 있습니다.
- 다른 인터페이스를 확장하거나, 4장에서 다루는 클래스가 그 모양을 구현하게 할 수 있습니다.
호출 시그니처
인터페이스는 속성뿐 아니라 함수의 모양도 나타낼 수 있습니다. 괄호 안에 매개변수를, 콜론 뒤에 반환 타입을 적은 호출 시그니처(call signature)를 쓰면, 그 인터페이스는 "이런 모양으로 부를 수 있는 함수"를 뜻합니다.
interface MathOperation {
(x: number, y: number): number;
}
const add: MathOperation = function (a, b) {
return a + b;
};
const multiply: MathOperation = function (num1, num2) {
return num1 * num2;
};
console.log(add(5, 3), multiply(4, 6));8 24add와 multiply의 매개변수에는 타입을 적지 않았지만, MathOperation이 정한 대로 둘 다 number로 정해집니다. 이것을 문맥에 의한 타입 결정(contextual typing)이라고 하며, 매개변수의 이름은 시그니처와 달라도 됩니다. 반환값이 문자열인 함수를 넣으면 다음과 같이 진단됩니다.
const invalidOp: MathOperation = function (a, b) {
return "결과: " + (a + b);
};error TS2322: Type '(a: number, b: number) => string' is not assignable to type 'MathOperation'.
Type 'string' is not assignable to type 'number'.첫 줄은 함수 전체의 타입이 MathOperation과 맞지 않는다는 뜻이고, 들여 쓴 둘째 줄은 그 이유를 한 단계 더 들어가 반환 타입 string이 number에 맞지 않는다고 짚습니다. 첫 줄에서 매개변수 a와 b가 number로 표시된 것도 앞에서 본 문맥에 의한 타입 결정 때문입니다.
인덱스 시그니처
속성의 이름을 미리 정할 수 없고, 이름과 값의 종류만 정해져 있을 때는 인덱스 시그니처(index signature)를 씁니다. [key: string]: string은 "문자열 이름의 속성은 몇 개든 있을 수 있고, 값은 모두 문자열"이라는 뜻이어서, 객체를 사전처럼 쓸 때 알맞습니다.
interface StringDictionary {
[key: string]: string;
}
const dictionary: StringDictionary = {};
dictionary["hello"] = "안녕하세요";
dictionary["apple"] = "사과";
console.log(dictionary["hello"], Object.keys(dictionary).length);안녕하세요 2값은 모두 문자열이어야 하므로, dictionary["year"] = 2024;는 Type 'number' is not assignable to type 'string'.으로 진단됩니다. 키의 타입으로는 string과 number, symbol, 그리고 data-로 시작하는 문자열처럼 모양을 정한 템플릿 리터럴 타입과 이들의 유니온을 쓸 수 있습니다. [index: number]: string처럼 숫자 키를 쓰면 문자열 배열 같은 값을 나타낼 수 있습니다.
인덱스 시그니처는 그 이름의 속성이 모두 있다고 보장하지 않습니다. 넣지 않은 dictionary["missing"]을 읽으면 실제 값은 undefined인데, 기본 설정에서 그 타입은 string으로 보입니다. 기본 타입에서 본 noUncheckedIndexedAccess를 켜면 이런 읽기의 타입에 undefined가 더해집니다.
인터페이스 확장
TYPE / RUNTIME / CONTRACT
인터페이스는 속성과 호출의 정적 계약이다
필수·선택·읽기 전용 속성, 호출 시그니처, 인덱스 시그니처와 확장을 각각 확인합니다. interface 선언은 객체를 만들거나 실제 입력을 검사하지 않습니다.
REQUIRED / OPTIONAL
name 필수 · isStudent 선택
Person의 name/age/nationalId는 필수이고 isStudent는 생략할 수 있습니다. 선택적 속성 읽기에는 undefined가 포함될 수 있습니다. required 속성 전부가 undefined를 허용하는 것은 아닙니다.
READONLY / STATIC
id 경로 재할당만 제한한다
본문 userProfile.id와 Person.nationalId의 재할당을 진단합니다. 런타임 freeze나 중첩 객체의 불변성, 다른 가변 참조의 수정을 자동으로 막지는 않습니다.
CALL / CONTRACT
(number, number) → number
MathOperation의 add(5,3)=8과 multiply(4,6)=24입니다. 반환값을 string으로 바꾸면 계약과 맞지 않습니다. 호출 시그니처는 readonly 속성과 다른 종류의 계약입니다.
INDEX / VALUE
동적 문자열 키 → 문자열 값
StringDictionary는 문자열 키에 string 값을 요구합니다. NumberIndexableArray도 숫자 인덱스의 string 계약입니다. 선언만으로 모든 가능한 키에 실제 값이 생기지는 않습니다.
EXTENDS / SHAPE
Shape.color에 각 도형 필드를 추가
Circle은 color+radius, Rectangle은 color+width+height를 요구합니다. 호환되는 속성으로 확장해야 하며 선언이 부모의 런타임 구현을 물려주는 것은 아닙니다.
OPTIONAL / ADDITIONAL OPTION
생략과 undefined 쓰기는 별도 옵션
exactOptionalPropertyTypes를 켜면 email?:string 같은 속성의 생략과 명시적 undefined 쓰기를 구분합니다. 이 절의 코드에는 해당 옵션 설정 예제가 없으므로 적용 설정을 함께 확인합니다.
필수·선택 속성, readonly, 호출·인덱스 시그니처와 확장을 각각 확인합니다. 선언은 실제 값을 만들지 않으며 readonly는 런타임 freeze가 아닙니다.
위 그림은 이 글에서 본 인터페이스의 요소를 모은 것입니다. 필수와 선택 속성, 읽기 전용 속성, 호출과 인덱스 시그니처는 모두 정적인 검사의 규칙이며, 선언이 실제 객체를 만들거나 바깥에서 들어온 값을 검사해 주지는 않습니다. 남은 요소인 확장은, 인터페이스가 extends로 다른 인터페이스의 속성을 모두 물려받고 새 속성을 더하는 것입니다.
interface Shape {
color: string;
}
interface Circle extends Shape {
radius: number;
}
interface Rectangle extends Shape {
width: number;
height: number;
}
const myCircle: Circle = { color: "blue", radius: 10 };
const myRectangle: Rectangle = { color: "red", width: 20, height: 30 };
console.log(myCircle.color, myRectangle.width);blue 20Circle과 Rectangle은 Shape를 확장했으므로, color를 다시 적지 않아도 이 속성을 요구합니다. 여기서 물려받는 것은 타입의 요구 사항이어서, myCircle 리터럴은 color와 radius의 값을 직접 가지고 있어야 합니다. { radius: 10 }만 넣으면 color가 빠졌다는 TS2741이 나옵니다.
interface Square extends Shape, Named처럼 여러 인터페이스를 한꺼번에 확장할 수도 있습니다. 공통 속성을 바탕 인터페이스로 모으면, 나중에 그 속성이 바뀌었을 때 고칠 곳이 한곳으로 줄어듭니다.
정리
- 객체 리터럴 타입은 속성의 이름과 타입을 적어 객체의 모양을 정하고, 없는 속성 접근은 TS2339, 빠진 속성은 TS2741, 틀린 값은 TS2322로 진단됩니다.
- 타입스크립트는 구조로 타입을 비교하므로 필요한 속성을 가진 객체는 속성이 더 있어도 할당되지만, 새 객체 리터럴에는 초과 속성 검사가 더해져 TS2353이 나옵니다.
- 선택적 속성은 읽을 때
undefined가 포함되고,readonly는 타입 검사에서만 대입을 막으며 중첩 객체와 실행 중의 객체까지 얼리지는 않습니다. - 인터페이스는 객체의 모양에 이름을 붙이는 선언으로, 속성 외에 호출 시그니처와 인덱스 시그니처를 담을 수 있고 자바스크립트에는 코드를 남기지 않습니다.
extends로 확장한 인터페이스는 바탕 인터페이스의 속성을 요구 사항으로 물려받습니다.
연습 문제
1. 중첩 객체와 readonly
다음 선언에서 profile.detail.name = "새 이름";과 profile.detail = { name: "새 이름" }; 가운데 진단되는 것은 어느 것인가요?
const profile: { readonly detail: { name: string } } = {
detail: { name: "수진" },
};해설 보기
readonly가 붙은 속성은 detail이므로, detail에 새 객체를 넣는 두 번째 줄이 Cannot assign to 'detail' because it is a read-only property.로 진단됩니다. 첫 번째 줄은 detail 안의 name을 바꾸는데, name에는 readonly가 없으므로 통과합니다. readonly는 그 속성 하나에만 적용되고 안쪽 객체로 전해지지 않으므로, 안쪽까지 막으려면 name에도 따로 붙여야 합니다.
2. 없는 키 읽기
위의 dictionary에서 "banana"의 번역을 읽어, 있으면 대문자로 바꾸어 출력하고 없으면 번역 없음을 출력하는 코드를 써 보세요. banana 키는 넣지 않았다고 합시다.
해설 보기
const translated = dictionary["banana"];
console.log(translated === undefined ? "번역 없음" : translated.toUpperCase());banana는 넣지 않았으므로 출력은 번역 없음입니다. 기본 설정에서 translated의 타입은 string이라 비교 없이 toUpperCase를 불러도 검사를 통과하지만, 실제로는 undefined여서 실행 중에 멈춥니다. 값이 문자열이라는 규칙과 요청한 키가 있는지는 서로 다른 문제이므로, 키의 존재는 이렇게 따로 확인합니다.
3. 어느 할당이 통과하는가
interface Point { x: number; y: number; }가 있을 때, 다음 두 할당은 각각 검사를 통과하나요? (가) const p1: Point = { x: 1, y: 2, z: 3 }; (나) const raw = { x: 1, y: 2, z: 3 }; const p2: Point = raw;
해설 보기
(가)는 새 객체 리터럴에 Point에 없는 z가 있으므로 초과 속성 검사에 걸려 TS2353이 나옵니다. (나)는 이미 만들어진 객체 raw를 옮기는 할당이어서 구조만 검사하며, x와 y를 모두 가지므로 통과합니다. 두 경우 모두 실행 중의 객체에는 z가 있지만, p2의 타입으로는 z를 읽을 수 없습니다.
다음 글 유니온과 인터섹션 타입에서는 여러 타입 가운데 하나를 뜻하는 유니온과, 여러 타입을 모두 만족하는 인터섹션으로 타입을 조합하는 방법을 다룹니다.