객체와 인터페이스
객체 리터럴과 interface로 필수·선택·읽기 전용 속성, 함수 타입, 인덱스 시그니처를 선언하고 계약을 확장합니다.
실제 애플리케이션에서는 기본 타입이 조합된 더 복잡한 데이터 구조, 즉 객체(Object)를 다루는 경우가 대부분입니다.
타입스크립트는 객체 구조를 명확히 정의하고 관리할 수 있는 기능을 제공하며, 그중 핵심이 인터페이스(Interface)입니다.
이 절에서는 객체 타입 정의 방법과 인터페이스의 개념, 활용법을 순서대로 정리합니다.
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 Types)을 사용하는 것입니다.
이는 중괄호 {} 안에 객체가 가질 속성(property)의 이름과 해당 속성의 타입을 직접 명시하는 방식입니다.
// 사람 정보를 담는 객체의 타입을 정의합니다.
let person: {
name: string;
age: number;
isStudent: boolean;
};
// 정의된 타입에 맞춰 객체를 할당합니다.
person = {
name: "김민준",
age: 25,
isStudent: true,
};
console.log(person.name); // 김민준
console.log(person.age); // 25
// 오류: 타입 정의에 없는 속성을 추가할 수 없습니다.
// person.address = "서울"; // Error: Property 'address' does not exist on type '{ name: string; age: number; isStudent: boolean; }'.
// 오류: 필수 속성을 누락할 수 없습니다.
// person = {
// name: "박서준",
// age: 30
// }; // Error: Property 'isStudent' is missing in type '{ name: string; age: number; }' but required in type '{ ... }'.
// 오류: 속성의 타입이 맞지 않으면 할당할 수 없습니다.
// person = {
// name: "최유리",
// age: "스물셋", // Error: Type 'string' is not assignable to type 'number'.
// isStudent: false,
// };위 예시에서 볼 수 있듯이, 객체 리터럴 타입은 객체가 가져야 할 속성의 이름, 그리고 각 속성의 타입을 엄격하게 검사합니다.
이 예제처럼 알려지지 않은 속성에 접근하거나 필수 속성을 누락하거나 속성 타입이 맞지 않으면 진단합니다. 다만 객체 타입이 추가 속성을 모두 금지하는 닫힌 구조는 아닙니다. 새 리터럴에 적용되는 초과 속성 검사와, 필요한 구조를 만족하는 중간 변수의 일반 할당을 구별합니다.
예시
const received = { name: "민준", age: 25, address: "서울" };
const personView: { name: string; age: number } = received;
console.log(personView.name); // 민준
console.log(received.address); // 서울
// console.log(personView.address); // 이 타입에는 address가 없습니다.
// const direct: { name: string; age: number } =
// { name: "민준", age: 25, address: "서울" }; // 새 리터럴의 초과 속성 진단중간 변수 received는 필요한 name과 age를 만족하므로 할당됩니다. 타입을 좁게 붙였다고 address가 실제 객체에서 삭제되지는 않습니다. personView와 received는 같은 객체를 가리키며, 어느 타입 경로로 접근했는지에 따라 허용되는 속성 접근이 달라집니다. 새 리터럴의 초과 속성 검사는 오타를 찾는 데도 도움이 됩니다.
이는 런타임 오류를 사전에 방지하는 매우 중요한 기능입니다.
선택적 속성
때로는 객체의 특정 속성이 필수적이지 않고 선택적으로 존재할 수 있도록 하고 싶을 때가 있습니다.
이럴 때는 속성 이름 뒤에 물음표(?)를 붙여 선택적 속성 (Optional Properties)으로 만들 수 있습니다.
let car: {
brand: string;
model: string;
year?: number; // year 속성은 선택적입니다.
};
car = {
brand: "Hyundai",
model: "Sonata",
year: 2023,
};
car = {
brand: "Kia",
model: "K5",
// year 속성을 생략해도 오류가 발생하지 않습니다.
};
console.log(car.year); // undefined: 직전 할당한 Kia 객체에는 year가 없습니다.year?: number는 year 속성이 number 타입이거나, 아예 존재하지 않아도 된다는 의미입니다. 위 코드는 두 번째 객체로 다시 할당한 뒤 읽으므로 마지막 car.year는 undefined입니다.
읽을 때 car.year는 생략 가능성 때문에 number | undefined입니다. 연도를 숫자 메서드로 표시하려면 undefined인지 먼저 확인합니다. 쓰기에서는 exactOptionalPropertyTypes: true일 때 year?: number의 생략과 year: undefined를 구별합니다. 후자도 허용하려는 계약이라면 year?: number | undefined라고 명시해야 합니다. 공식 옵션 설명은 이 조건을 별도로 설명합니다.
읽기 전용 속성
객체의 특정 속성을 해당 타입 경로에서 재할당하지 못하도록 표현할 때는 속성 이름 앞에 readonly 키워드를 붙여 읽기 전용 속성 (Readonly Properties)으로 만들 수 있습니다.
let userProfile: {
readonly id: string;
name: string;
};
userProfile = {
id: "user_abc_123",
name: "이수진",
};
// userProfile.id = "new_id"; // Error: Cannot assign to 'id' because it is a read-only property.
userProfile.name = "이수진님"; // name은 변경 가능합니다.
console.log(userProfile.id); // user_abc_123readonly는 해당 타입 경로에서 속성을 재할당하지 못하게 하는 정적 제약입니다. 런타임 객체를 동결하거나 중첩 객체·다른 가변 참조를 통한 수정을 막지는 않습니다.
불변성(Immutable)이 필요한 설계에서 쓰기 의도를 제한하는 데 유용하지만, 실제 객체 전체를 불변으로 만들려면 다른 변경 경로도 함께 설계해야 합니다.
직접 판단하기: 다음 선언에서 profile.detail.name = "새 이름"과 profile.detail = { name: "새 이름" } 중 어느 것이 진단될까요? 중첩 객체까지 동결되었다고 가정하지 말고, readonly가 붙은 속성을 먼저 찾으세요.
const profile: { readonly detail: { name: string } } = {
detail: { name: "수진" },
};readonly가 제한하는 쓰기 위치
detail 자체를 새 객체로 재할당하는 두 번째 쓰기가 진단됩니다. 첫 번째는 그 안의 가변 속성 name을 바꾸므로 허용됩니다. readonly가 재귀적으로 전파되지 않는다는 점을 확인할 수 있습니다.
인터페이스
객체 리터럴 타입으로 객체의 형태를 정의하는 것은 간단한 경우에 유용합니다.
하지만 동일한 객체 타입을 여러 번 사용하거나, 복잡한 타입을 정의하고 확장해야 할 때는 인터페이스 (Interface)를 사용하는 것이 훨씬 효과적입니다.
인터페이스는 타입스크립트에서 객체의 '모양(Shape)'을 정의하는 강력한 도구입니다.
특정 객체가 어떤 속성과 메서드를 가져야 하는지 명세하는 청사진과 같습니다.
// 'Person'이라는 이름의 인터페이스를 정의합니다.
interface Person {
name: string;
age: number;
isStudent?: boolean; // 선택적 속성도 정의 가능
readonly nationalId: string; // 읽기 전용 속성도 정의 가능
}
// Person 인터페이스를 따르는 객체를 생성합니다.
let student: Person = {
name: "김영희",
age: 22,
isStudent: true,
nationalId: "981234-5678901",
};
let teacher: Person = {
name: "박선생",
age: 40,
nationalId: "789012-3456789",
// isStudent 속성은 선택적이므로 생략해도 오류가 없습니다.
};
console.log(student.name); // 김영희
// student.nationalId = "새로운ID"; // Error: nationalId는 읽기 전용입니다.interface 키워드를 사용하여 인터페이스를 정의하고, 정의된 인터페이스 이름을 타입으로 사용합니다.
인터페이스는 객체 리터럴 타입과 마찬가지로 선택적 속성(?)과 읽기 전용 속성(readonly)을 가질 수 있습니다.
인터페이스의 장점
재사용성: 한 번 정의된 인터페이스는 여러 곳에서 재사용될 수 있어 코드의 중복을 줄이고 일관성을 유지할 수 있습니다.
명확성: 복잡한 객체 구조를 명확한 이름으로 추상화하여 코드의 가독성을 높입니다.
협업 용이성: 팀원 간에 데이터 모델에 대한 명확한 약속을 공유할 수 있어 협업 효율을 높입니다.
확장성: 인터페이스는 호환되는 다른 인터페이스를 확장(extends)할 수 있고, 클래스는 인터페이스를 구현(implements)하여 인스턴스 계약을 검사받을 수 있습니다.
함수 타입 정의
인터페이스는 객체 속성뿐만 아니라, 함수의 타입 (Function Types in Interfaces)도 정의할 수 있습니다.
이는 특정 형태의 함수를 요구할 때 유용합니다.
interface MathOperation {
(x: number, y: number): number; // 매개변수 타입과 반환 타입 정의
}
let add: MathOperation = function (a, b) {
return a + b;
};
let multiply: MathOperation = function (num1, num2) {
return num1 * num2;
};
console.log(add(5, 3)); // 8
console.log(multiply(4, 6)); // 24
// 오류: 반환 타입이 맞지 않습니다.
// let invalidOp: MathOperation = function (a, b) {
// return "결과: " + (a + b); // Error: Type 'string' is not assignable to type 'number'.
// };MathOperation 인터페이스는 두 개의 number 타입 매개변수를 받고 number 타입의 값을 반환하는 함수의 형태를 정의합니다.
이 인터페이스를 따르는 함수들은 해당 형태를 반드시 지켜야 합니다.
인덱스 시그니처
객체의 속성 이름이 정해져 있지 않고 동적으로 변할 수 있을 때 인덱스 시그니처 (Index Signatures)를 사용하여 정의할 수 있습니다.
이는 예를 들어 객체를 딕셔너리(Dictionary)나 맵(Map)처럼 사용할 때 유용합니다.
interface StringDictionary {
[key: string]: string; // 문자열 키에 문자열 값을 가집니다.
}
let myDictionary: StringDictionary = {};
myDictionary["hello"] = "안녕하세요";
myDictionary["apple"] = "사과";
myDictionary["book"] = "책";
console.log(myDictionary["hello"]); // 안녕하세요
// 오류: [key: string]에 string 값만 허용하므로 숫자를 할당할 수 없습니다.
// myDictionary["year"] = 2024; // Error: Type 'number' is not assignable to type 'string'.
interface NumberIndexableArray {
[index: number]: string; // 숫자 인덱스에 문자열 값을 가집니다.
}
let myStringArray: NumberIndexableArray = ["a", "b", "c"];
console.log(myStringArray[0]); // a인덱스 시그니처는 [key: TKey]: TValue 형태로 정의하며, TKey에는 string·number뿐 아니라 symbol 및 지원되는 템플릿 문자열 패턴도 사용할 수 있고, TValue는 해당 키에 해당하는 값의 타입입니다.
myDictionary["missing"]처럼 넣지 않은 키를 읽으면 실제 값은 undefined입니다. 인덱스 시그니처는 그 키가 모두 존재한다고 저장소를 채워 주지 않습니다. noUncheckedIndexedAccess: true를 켜면 이런 읽기에도 결측 가능성이 반영됩니다.
직접 확인하기: myDictionary["banana"]를 읽어, 있을 때만 대문자로 바꾸고 없으면 번역 없음을 출력하는 코드를 작성해 보세요. 위 예제에는 banana 키를 추가하지 않았다고 가정합니다.
키의 존재를 확인한 출력
const translated = myDictionary["banana"];
console.log(translated === undefined ? "번역 없음" : translated.toUpperCase());출력은 번역 없음입니다. 값이 문자열이라는 계약과 요청한 키의 존재 여부를 분리해 확인합니다.
인터페이스 확장
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 { // Shape 인터페이스를 확장합니다.
radius: number;
}
interface Rectangle extends Shape { // Shape 인터페이스를 확장합니다.
width: number;
height: number;
}
let myCircle: Circle = {
color: "blue",
radius: 10,
};
let myRectangle: Rectangle = {
color: "red",
width: 20,
height: 30,
};
console.log(myCircle.color); // blue
console.log(myRectangle.width); // 20Circle과 Rectangle 인터페이스는 Shape 인터페이스를 확장했기 때문에 color 속성을 별도로 정의하지 않아도 자동으로 포함됩니다.
여기서 자동으로 포함되는 것은 타입의 요구 사항입니다. Circle 선언만으로 실행 중 객체에 color를 만들어 넣지는 않습니다. 실제 myCircle 리터럴이 직접 color와 radius 값을 제공하며, 외부 JSON의 같은 구조를 확인하는 검증 코드도 별도로 필요합니다.
이는 코드의 재사용성을 높이고 관련된 타입들을 계층적으로 관리하는 데 큰 도움이 됩니다.
객체 타입을 설계할 때는 단순히 문법을 외우기보다, 어떤 속성이 필수인지, 어떤 속성이 선택인지, 어디까지 재사용 가능한 계약으로 묶을지 함께 판단해야 합니다.
객체와 인터페이스를 설계할 때는 처음 한 번의 선언보다 이후 변경을 견디는 계약인지가 더 중요합니다.
이번 절에서는 타입스크립트에서 객체의 타입을 정의하는 객체 리터럴 타입과 인터페이스를 정리했습니다.
인터페이스는 복잡한 객체 구조를 이름 붙여 재사용하고 협업 시 타입 계약을 명확히 할 때 사용합니다.