객체 타입
객체 타입 (Object Types)
자바스크립트에서 데이터를 묶고 주고받는 가장 기본적인 방식은 객체예요. TypeScript에서는 그런 객체들을 객체 타입(object types) 으로 표현합니다.
앞서 봤듯이 익명(anonymous)으로 쓸 수도 있고,
function greet(person: { name: string; age: number }) {
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
return "Hello " + person.name;
}
interface로 이름을 붙이거나,
interface Person {
// ^^^^^^
name: string;
age: number;
}
function greet(person: Person) {
return "Hello " + person.name;
}
타입 별칭(type alias)으로 이름을 붙일 수도 있어요.
type Person = {
// ^^^^^^
name: string;
age: number;
};
function greet(person: Person) {
return "Hello " + person.name;
}
위 세 예시 모두 name(반드시 string)과 age(반드시 number) 프로퍼티를 가진 객체를 받는 함수를 작성한 거예요.
빠른 참조 (Quick Reference)
일상에서 쓰는 중요한 문법을 한눈에 보고 싶다면, type과 interface에 대한 치트시트가 준비되어 있어요.
프로퍼티 수식어 (Property Modifiers)
객체 타입의 각 프로퍼티는 타입, 프로퍼티가 선택적인지 여부, 그리고 프로퍼티를 쓸 수 있는지 여부를 지정할 수 있어요.
선택적 프로퍼티 (Optional Properties)
대부분의 경우 우리는 프로퍼티가 설정되어 있을 수도 있는 객체를 다루게 돼요. 그런 경우 이름 끝에 물음표(?)를 붙여 그 프로퍼티를 선택적(optional) 으로 표시할 수 있어요.
interface Shape {}
declare function getShape(): Shape;
// ---cut---
interface PaintOptions {
shape: Shape;
xPos?: number;
// ^
yPos?: number;
// ^
}
function paintShape(opts: PaintOptions) {
// ...
}
const shape = getShape();
paintShape({ shape });
paintShape({ shape, xPos: 100 });
paintShape({ shape, yPos: 100 });
paintShape({ shape, xPos: 100, yPos: 100 });
이 예시에서 xPos와 yPos는 둘 다 선택적으로 간주돼요. 둘 중 하나를 골라 제공할 수 있으니, 위의 paintShape 호출은 모두 유효하죠. 선택적이라는 것은 단지 "프로퍼티가 설정되어 있다면 그 타입이 특정 타입이어야 한다"는 뜻일 뿐이에요.
그 프로퍼티를 읽을 수도 있는데, 다만 strictNullChecks 아래에서 읽으면 TypeScript가 잠재적으로 undefined일 수 있다고 알려줘요.
interface Shape {}
declare function getShape(): Shape;
interface PaintOptions {
shape: Shape;
xPos?: number;
yPos?: number;
}
// ---cut---
function paintShape(opts: PaintOptions) {
let xPos = opts.xPos;
// ^?
let yPos = opts.yPos;
// ^?
// ...
}
자바스크립트에서는 프로퍼티가 한 번도 설정된 적이 없어도 여전히 접근할 수 있어요. 단지 undefined 값을 돌려줄 뿐이죠. undefined인지 명시적으로 검사해서 특별히 처리하면 돼요.
interface Shape {}
declare function getShape(): Shape;
interface PaintOptions {
shape: Shape;
xPos?: number;
yPos?: number;
}
// ---cut---
function paintShape(opts: PaintOptions) {
let xPos = opts.xPos === undefined ? 0 : opts.xPos;
// ^?
let yPos = opts.yPos === undefined ? 0 : opts.yPos;
// ^?
// ...
}
지정되지 않은 값에 기본값을 설정하는 이 패턴은 너무 흔해서, 자바스크립트에는 이를 지원하는 문법이 있다는 점을 눈여겨보세요.
interface Shape {}
declare function getShape(): Shape;
interface PaintOptions {
shape: Shape;
xPos?: number;
yPos?: number;
}
// ---cut---
function paintShape({ shape, xPos = 0, yPos = 0 }: PaintOptions) {
console.log("x coordinate at", xPos);
// ^?
console.log("y coordinate at", yPos);
// ^?
// ...
}
여기서는 구조 분해 패턴(destructuring pattern)을 paintShape의 파라미터에 사용하고, xPos와 yPos에 기본값(default values)을 제공했어요. 이제 paintShape 본문 안에서는 xPos와 yPos가 확실히 존재하지만, paintShape를 호출하는 쪽에서는 여전히 선택적이에요.
참고로 현재 구조 분해 패턴 안에는 타입 표기를 넣을 방법이 없어요. 아래와 같은 문법이 자바스크립트에서 이미 다른 의미를 갖고 있기 때문이에요.
// @noImplicitAny: false // @errors: 2552 2304 interface Shape {} declare function render(x: unknown); // ---cut--- function draw({ shape: Shape, xPos: number = 100 /*...*/ }) { render(shape); render(xPos); }객체 구조 분해 패턴에서
shape: Shape는 "shape프로퍼티를 꺼내Shape라는 이름의 지역 변수로 다시 정의한다"는 뜻이에요. 마찬가지로xPos: number는 파라미터의xPos를 값으로 하는number라는 이름의 변수를 만듭니다.
readonly 프로퍼티
프로퍼티는 TypeScript에서 readonly로 표시할 수도 있어요. 런타임 동작은 바꾸지 않지만, readonly로 표시된 프로퍼티는 타입 검사 중에 쓸 수 없어요.
// @errors: 2540
interface SomeType {
readonly prop: string;
}
function doSomething(obj: SomeType) {
// We can read from 'obj.prop'.
console.log(`prop has the value '${obj.prop}'.`);
// But we can't re-assign it.
obj.prop = "hello";
}
readonly 수식어를 쓴다고 해서 값이 완전히 불변(immutable)이라는 뜻은 아니에요. 다시 말해 내부 내용물을 바꿀 수 없다는 의미는 아닌 거죠. 단지 그 프로퍼티 자체를 다시 쓸 수 없다는 뜻일 뿐이에요.
// @errors: 2540
interface Home {
readonly resident: { name: string; age: number };
}
function visitForBirthday(home: Home) {
// We can read and update properties from 'home.resident'.
console.log(`Happy birthday ${home.resident.name}!`);
home.resident.age++;
}
function evict(home: Home) {
// But we can't write to the 'resident' property itself on a 'Home'.
home.resident = {
name: "Victor the Evictor",
age: 42,
};
}
readonly가 무엇을 의미하는지에 대한 기대치를 잘 관리하는 게 중요해요. 이는 개발 시점에 객체를 어떻게 사용해야 하는지 TypeScript에 의도를 알리는 데 유용할 뿐이에요. TypeScript는 두 타입의 프로퍼티가 readonly인지 여부를 타입 호환성 검사에 반영하지 않으므로, readonly 프로퍼티도 별칭(aliasing)을 통해 바뀔 수 있어요.
interface Person {
name: string;
age: number;
}
interface ReadonlyPerson {
readonly name: string;
readonly age: number;
}
let writablePerson: Person = {
name: "Person McPersonface",
age: 42,
};
// works
let readonlyPerson: ReadonlyPerson = writablePerson;
console.log(readonlyPerson.age); // prints '42'
writablePerson.age++;
console.log(readonlyPerson.age); // prints '43'
매핑 수식어(mapping modifiers)를 사용하면 readonly 속성을 제거할 수도 있어요.
인덱스 시그니처 (Index Signatures)
때로는 타입의 프로퍼티 이름을 미리 전부 알 수 없지만, 값의 형태는 알고 있을 때가 있어요. 그런 경우 인덱스 시그니처(index signature)를 사용해 가능한 값들의 타입을 설명할 수 있어요. 예를 들어 볼게요.
declare function getStringArray(): StringArray;
// ---cut---
interface StringArray {
[index: number]: string;
}
const myArray: StringArray = getStringArray();
const secondItem = myArray[1];
// ^?
위에서 StringArray 인터페이스가 인덱스 시그니처를 갖고 있어요. 이 인덱스 시그니처는 StringArray를 number로 인덱싱하면 string을 돌려준다는 뜻이에요.
인덱스 시그니처 프로퍼티에 허용되는 타입은 몇 가지뿐이에요: string, number, symbol, 템플릿 문자열 패턴, 그리고 이것들로만 이루어진 유니온 타입입니다.
여러 타입의 인덱서를 지원하는 것도 가능합니다...
여러 타입의 인덱서를 지원하는 것이 가능해요. 다만 `number`와 `string` 인덱서를 둘 다 사용할 때는, 숫자 인덱서가 반환하는 타입이 문자열 인덱서가 반환하는 타입의 서브타입이어야 한다는 점을 기억하세요. `number`로 인덱싱할 때 자바스크립트는 실제로 객체에 인덱싱하기 전에 그 숫자를 `string`으로 변환하기 때문이에요. 즉 `100`(a `number`)으로 인덱싱하는 것은 `"100"`(a `string`)으로 인덱싱하는 것과 같다는 뜻이므로, 둘은 일관되어야 해요.
// @errors: 2413
// @strictPropertyInitialization: false
interface Animal {
name: string;
}
interface Dog extends Animal {
breed: string;
}
// Error: indexing with a numeric string might get you a completely separate type of Animal!
interface NotOkay {
[x: number]: Animal;
[x: string]: Dog;
}
문자열 인덱스 시그니처는 "사전(dictionary)" 패턴을 표현하는 강력한 방법이지만, 동시에 모든 프로퍼티가 반환 타입과 일치하도록 강제하기도 해요. 문자열 인덱스는 obj.property가 obj["property"]로도 접근 가능하다고 선언하기 때문이에요. 다음 예시에서 name의 타입이 문자열 인덱스의 타입과 일치하지 않아서 타입 검사기가 에러를 줍니다.
// @errors: 2411
// @errors: 2411
interface NumberDictionary {
[index: string]: number;
length: number; // ok
name: string;
}
하지만 인덱스 시그니처가 프로퍼티 타입들의 유니온이라면, 서로 다른 타입의 프로퍼티도 괜찮아요.
interface NumberOrStringDictionary {
[index: string]: number | string;
length: number; // ok, length is a number
name: string; // ok, name is a string
}
마지막으로, 인덱스에 할당하는 것을 막기 위해 인덱스 시그니처를 readonly로 만들 수 있어요.
declare function getReadOnlyStringArray(): ReadonlyStringArray;
// ---cut---
// @errors: 2542
interface ReadonlyStringArray {
readonly [index: number]: string;
}
let myArray: ReadonlyStringArray = getReadOnlyStringArray();
myArray[2] = "Mallory";
myArray[2]는 인덱스 시그니처가 readonly이므로 설정할 수 없어요.
초과 프로퍼티 검사 (Excess Property Checks)
객체가 어디서 어떻게 타입을 할당받는지는 타입 시스템에서 차이를 만들 수 있어요. 이에 대한 핵심 예시 중 하나가 초과 프로퍼티 검사인데, 객체가 생성되고 객체 타입에 할당될 때 객체를 더 철저하게 검증해요.
// @errors: 2345 2739
interface SquareConfig {
color?: string;
width?: number;
}
function createSquare(config: SquareConfig): { color: string; area: number } {
return {
color: config.color || "red",
area: config.width ? config.width * config.width : 20,
};
}
let mySquare = createSquare({ colour: "red", width: 100 });
createSquare에 주어진 인자가 color가 아니라 colour 로 철자가 적혀 있는 걸 눈여겨보세요. 순수 자바스크립트에서는 이런 일이 조용히 지나가요.
이 프로그램이 올바르게 타입된 것이라고 주장할 수도 있어요. width 프로퍼티들은 호환되고, color 프로퍼티는 없으며, 여분의 colour 프로퍼티는 중요하지 않으니까요.
하지만 TypeScript는 여기에 버그가 있을 거라는 입장을 취해요. 객체 리터럴은 특별 대우를 받아, 다른 변수에 할당하거나 인자로 전달할 때 초과 프로퍼티 검사(excess property checking) 를 거칩니다. 객체 리터럴에 "대상 타입"이 가지지 않은 프로퍼티가 있으면 에러를 보게 돼요.
// @errors: 2345 2739
interface SquareConfig {
color?: string;
width?: number;
}
function createSquare(config: SquareConfig): { color: string; area: number } {
return {
color: config.color || "red",
area: config.width ? config.width * config.width : 20,
};
}
// ---cut---
let mySquare = createSquare({ colour: "red", width: 100 });
이 검사를 우회하는 것은 사실 아주 간단해요. 가장 쉬운 방법은 타입 단언(type assertion)을 쓰는 거예요.
// @errors: 2345 2739
interface SquareConfig {
color?: string;
width?: number;
}
function createSquare(config: SquareConfig): { color: string; area: number } {
return {
color: config.color || "red",
area: config.width ? config.width * config.width : 20,
};
}
// ---cut---
let mySquare = createSquare({ width: 100, opacity: 0.5 } as SquareConfig);
하지만 객체가 특별한 방식으로 쓰이는 여분의 프로퍼티를 가질 수 있다는 게 확실하다면, 문자열 인덱스 시그니처를 추가하는 쪽이 더 나은 접근일 수 있어요. SquareConfig가 위 타입의 color와 width 프로퍼티를 가질 수 있지만, 또한 다른 프로퍼티를 얼마든지 가질 수 있다면 이렇게 정의할 수 있어요.
interface SquareConfig {
color?: string;
width?: number;
[propName: string]: unknown;
}
여기서 "SquareConfig는 프로퍼티를 얼마든지 가질 수 있고, color나 width가 아니라면 그 타입은 중요하지 않다"고 말하고 있어요.
이 검사를 우회하는 마지막 방법 하나를 소개할게요. 다소 놀랄 수도 있는데, 객체를 다른 변수에 할당하는 거예요. squareOptions를 할당하는 것은 초과 프로퍼티 검사를 거치지 않으므로 컴파일러는 에러를 주지 않아요.
interface SquareConfig {
color?: string;
width?: number;
}
function createSquare(config: SquareConfig): { color: string; area: number } {
return {
color: config.color || "red",
area: config.width ? config.width * config.width : 20,
};
}
// ---cut---
let squareOptions = { colour: "red", width: 100 };
let mySquare = createSquare(squareOptions);
위 우회 방법은 squareOptions와 SquareConfig 사이에 공통 프로퍼티가 있을 때만 동작해요. 이 예시에서는 width 프로퍼티가 그 역할을 했죠. 하지만 변수에 공통 객체 프로퍼티가 하나도 없으면 실패해요. 예를 들어 볼게요.
// @errors: 2559
interface SquareConfig {
color?: string;
width?: number;
}
function createSquare(config: SquareConfig): { color: string; area: number } {
return {
color: config.color || "red",
area: config.width ? config.width * config.width : 20,
};
}
// ---cut---
let squareOptions = { colour: "red" };
let mySquare = createSquare(squareOptions);
위 같은 단순한 코드에서는 이런 검사를 "우회"하려고 애쓰지 않는 게 좋다는 점을 기억해 주세요. 메서드와 상태를 가진 더 복잡한 객체 리터럴에서는 이런 기법을 염두에 둘 필요가 있겠지만, 초과 프로퍼티 에러의 대부분은 실제로는 버그예요.
즉, 옵션 뭉치(option bags) 같은 곳에서 초과 프로퍼티 검사 문제를 겪고 있다면 타입 선언 중 일부를 수정해야 할지도 몰라요. 이 경우, color나 colour 프로퍼티를 모두 가진 객체를 createSquare에 전달해도 괜찮다면, SquareConfig의 정의를 그에 맞게 고쳐야 해요.
타입 확장 (Extending Types)
다른 타입보다 더 구체적인 버전인 타입을 갖는 것은 꽤 흔해요. 예를 들어 미국에서 편지와 소포를 보내는 데 필요한 필드를 설명하는 BasicAddress 타입이 있다고 해 볼게요.
interface BasicAddress {
name?: string;
street: string;
city: string;
country: string;
postalCode: string;
}
어떤 상황에서는 그것으로 충분하지만, 주소의 건물에 여러 세대가 있으면 주소에 보통 호수(unit number)가 붙어요. 그러면 AddressWithUnit을 설명할 수 있겠죠.
interface AddressWithUnit {
name?: string;
unit: string;
//^^^^^^^^^^^^^
street: string;
city: string;
country: string;
postalCode: string;
}
이렇게 하면 기능은 하지만, 변경이 순전히 추가적(additive)임에도 BasicAddress의 다른 필드들을 전부 반복해야 한다는 단점이 있어요. 대신 원래 BasicAddress 타입을 확장(extend)하고, AddressWithUnit에 고유한 새 필드만 추가할 수 있어요.
interface BasicAddress {
name?: string;
street: string;
city: string;
country: string;
postalCode: string;
}
interface AddressWithUnit extends BasicAddress {
unit: string;
}
interface의 extends 키워드를 쓰면 다른 이름 있는 타입의 멤버를 효과적으로 복사하고, 원하는 새 멤버를 추가할 수 있어요. 이는 작성해야 하는 타입 선언 보일러플레이트를 줄이고, 같은 프로퍼티의 여러 선언이 서로 관련될 수 있음을 알리는 데 유용해요. 예를 들어 AddressWithUnit은 street 프로퍼티를 반복할 필요가 없었어요. street가 BasicAddress에서 온 것이므로, 읽는 사람은 두 타입이 어떤 식으로든 관련되어 있다는 걸 알겠죠.
interface는 여러 타입에서 확장할 수도 있어요.
interface Colorful {
color: string;
}
interface Circle {
radius: number;
}
interface ColorfulCircle extends Colorful, Circle {}
const cc: ColorfulCircle = {
color: "red",
radius: 42,
};
교차 타입 (Intersection Types)
interface는 타입을 확장함으로써 다른 타입들로부터 새 타입을 만들어 냈어요. TypeScript는 주로 기존 객체 타입을 결합하는 데 쓰는 교차 타입(intersection types) 이라는 또 다른 구조도 제공합니다.
교차 타입은 & 연산자로 정의해요.
interface Colorful {
color: string;
}
interface Circle {
radius: number;
}
type ColorfulCircle = Colorful & Circle;
여기서 Colorful과 Circle을 교차(intersect)해서, Colorful 그리고 Circle의 모든 멤버를 가진 새 타입을 만들어 냈어요.
// @errors: 2345
interface Colorful {
color: string;
}
interface Circle {
radius: number;
}
// ---cut---
function draw(circle: Colorful & Circle) {
console.log(`Color was ${circle.color}`);
console.log(`Radius was ${circle.radius}`);
}
// okay
draw({ color: "blue", radius: 42 });
// oops
draw({ color: "red", raidus: 42 });
인터페이스 확장 vs. 교차
방금 타입을 결합하는 두 가지 방법을 살펴봤는데, 비슷해 보이지만 사실 미묘하게 달라요. 인터페이스에서는 extends 절을 사용해 다른 타입에서 확장할 수 있었고, 교차에서는 비슷한 일을 하고 결과에 타입 별칭으로 이름을 붙일 수 있었죠. 둘의 주요 차이는 충돌을 어떻게 처리하느냐인데, 이 차이가 인터페이스와 교차 타입의 타입 별칭 중 하나를 고를 때의 주요 이유가 돼요.
같은 이름으로 인터페이스가 정의되면, TypeScript는 프로퍼티들이 호환된다면 병합(merge)을 시도해요. 프로퍼티들이 호환되지 않으면(즉 같은 프로퍼티 이름을 가졌지만 타입이 다르다면), TypeScript는 에러를 발생시킵니다.
교차 타입의 경우, 타입이 다른 프로퍼티는 자동으로 병합됩니다. 나중에 그 타입을 사용할 때 TypeScript는 프로퍼티가 두 타입을 동시에 만족하길 기대하는데, 이는 예상치 못한 결과를 낳을 수 있어요.
예를 들어 다음 코드는 프로퍼티가 호환되지 않으므로 에러를 던집니다.
interface Person {
name: string;
}
interface Person {
name: number;
}
반대로 다음 코드는 컴파일되지만, 결과는 never 타입이에요.
interface Person1 {
name: string;
}
interface Person2 {
name: number;
}
type Staff = Person1 & Person2
declare const staffer: Staff;
staffer.name;
// ^?
이 경우 Staff는 name 프로퍼티가 문자열이면서 동시에 숫자여야 하므로, 그 프로퍼티는 never 타입이 됩니다.
제네릭 객체 타입 (Generic Object Types)
string, number, Giraffe 등 무엇이든 담을 수 있는 Box 타입을 상상해 봅시다.
interface Box {
contents: any;
}
지금 contents 프로퍼티는 any로 타입되어 있는데, 동작은 하지만 나중에 사고를 부를 수 있어요.
대신 unknown을 쓸 수도 있지만, 그러면 contents의 타입을 이미 알고 있는 경우에도 예방적 검사를 하거나, 실수하기 쉬운 타입 단언을 써야 한다는 뜻이 돼요.
interface Box {
contents: unknown;
}
let x: Box = {
contents: "hello world",
};
// we could check 'x.contents'
if (typeof x.contents === "string") {
console.log(x.contents.toLowerCase());
}
// or we could use a type assertion
console.log((x.contents as string).toLowerCase());
타입 안전한 접근 하나는, contents의 각 타입에 대해 서로 다른 Box 타입을 따로 만드는 거예요.
// @errors: 2322
interface NumberBox {
contents: number;
}
interface StringBox {
contents: string;
}
interface BooleanBox {
contents: boolean;
}
하지만 그렇게 하면 이 타입들을 다루기 위해 함수를 각각 만들거나 함수의 오버로드(overload)를 만들어야 해요.
interface NumberBox {
contents: number;
}
interface StringBox {
contents: string;
}
interface BooleanBox {
contents: boolean;
}
// ---cut---
function setContents(box: StringBox, newContents: string): void;
function setContents(box: NumberBox, newContents: number): void;
function setContents(box: BooleanBox, newContents: boolean): void;
function setContents(box: { contents: any }, newContents: any) {
box.contents = newContents;
}
보일러플레이트가 정말 많죠. 게다가 나중에 새 타입과 오버로드를 도입해야 할 수도 있어요. 박스 타입과 오버로드가 사실상 전부 같으니 이건 답답한 일이에요.
대신 타입 파라미터(type parameter) 를 선언하는 제네릭(제네릭) Box 타입을 만들 수 있어요.
interface Box<Type> {
contents: Type;
}
여러분은 이걸 "Type의 Box는 contents가 타입 Type을 가진 것"이라고 읽을 수 있어요. 나중에 Box를 참조할 때는 Type 자리에 타입 인자(type argument) 를 넣어야 해요.
interface Box<Type> {
contents: Type;
}
// ---cut---
let box: Box<string>;
Box를 실제 타입의 템플릿으로 생각해 보세요. Type은 다른 타입으로 대체될 자리표시자(placeholder)예요. TypeScript가 Box<string>을 보면 Box<Type>의 Type 인스턴스마다 string을 대체하고, { contents: string } 같은 것으로 처리합니다. 다시 말해 Box<string>과 앞서 본 StringBox는 동일하게 동작해요.
interface Box<Type> {
contents: Type;
}
interface StringBox {
contents: string;
}
let boxA: Box<string> = { contents: "hello" };
boxA.contents;
// ^?
let boxB: StringBox = { contents: "world" };
boxB.contents;
// ^?
Box는 Type을 무엇으로든 치환할 수 있다는 점에서 재사용 가능해요. 즉 새 타입을 위한 박스가 필요할 때 새 Box 타입을 선언할 필요가 전혀 없다는 뜻이에요(물론 원한다면 선언할 수도 있지만요).
interface Box<Type> {
contents: Type;
}
interface Apple {
// ....
}
// Same as '{ contents: Apple }'.
type AppleBox = Box<Apple>;
이것은 또한, 제네릭 함수(generic functions)를 사용해 오버로드를 완전히 피할 수 있다는 의미이기도 해요.
interface Box<Type> {
contents: Type;
}
// ---cut---
function setContents<Type>(box: Box<Type>, newContents: Type) {
box.contents = newContents;
}
타입 별칭도 제네릭이 될 수 있다는 점은 언급할 가치가 있어요. 우리가 만든 새 Box<Type> 인터페이스를
interface Box<Type> {
contents: Type;
}
타입 별칭을 사용해 정의할 수도 있었어요.
type Box<Type> = {
contents: Type;
};
타입 별칭은 인터페이스와 달리 객체 타입보다 더 많은 것을 설명할 수 있으므로, 다른 종류의 제네릭 헬퍼 타입을 작성할 때도 쓸 수 있어요.
// @errors: 2575
type OrNull<Type> = Type | null;
type OneOrMany<Type> = Type | Type[];
type OneOrManyOrNull<Type> = OrNull<OneOrMany<Type>>;
// ^?
type OneOrManyOrNullStrings = OneOrManyOrNull<string>;
// ^?
타입 별칭은 잠시 후에 다시 다룰게요.
Array 타입
제네릭 객체 타입은 종종 담고 있는 요소의 타입과 무관하게 동작하는 일종의 컨테이너 타입이에요. 데이터 구조가 이렇게 동작하는 게 여러 데이터 타입에 걸쳐 재사용할 수 있어 이상적입니다.
이 핸드북을 통해 내내 그런 타입과 함께 일해 왔다는 사실이 드러나요. 바로 Array 타입입니다. number[]나 string[]처럼 타입을 쓸 때마다, 그것은 사실 Array<number>와 Array<string>의 줄임말이에요.
function doSomething(value: Array<string>) {
// ...
}
let myArray: string[] = ["hello", "world"];
// either of these work!
doSomething(myArray);
doSomething(new Array("hello", "world"));
위 Box 타입과 비슷하게, Array 자체도 제네릭 타입이에요.
// @noLib: true
interface Number {}
interface String {}
interface Boolean {}
interface Symbol {}
// ---cut---
interface Array<Type> {
/**
* Gets or sets the length of the array.
*/
length: number;
/**
* Removes the last element from an array and returns it.
*/
pop(): Type | undefined;
/**
* Appends new elements to an array, and returns the new length of the array.
*/
push(...items: Type[]): number;
// ...
}
현대 자바스크립트는 제네릭인 다른 데이터 구조도 제공해요. Map<K, V>, Set<T>, Promise<T> 같은 것들이죠. 이는 Map, Set, Promise가 동작하는 방식 덕분에 어떤 타입의 집합과도 함께 동작할 수 있다는 뜻일 뿐이에요.
ReadonlyArray 타입
ReadonlyArray는 바뀌면 안 되는 배열을 설명하는 특별한 타입이에요.
// @errors: 2339
function doStuff(values: ReadonlyArray<string>) {
// We can read from 'values'...
const copy = values.slice();
console.log(`The first value is ${values[0]}`);
// ...but we can't mutate 'values'.
values.push("hello!");
}
프로퍼티의 readonly 수식어와 비슷하게, 이는 주로 의도를 표현하는 도구예요. ReadonlyArray를 반환하는 함수를 보면 내용을 전혀 바꾸려는 게 아니라는 뜻이고, ReadonlyArray를 소비하는 함수를 보면 내용을 바꿀 걱정 없이 아무 배열이나 그 함수에 넘길 수 있다는 뜻이에요.
Array와 달리 사용할 수 있는 ReadonlyArray 생성자는 없어요.
// @errors: 2693
new ReadonlyArray("red", "green", "blue");
대신 일반 Array를 ReadonlyArray에 할당할 수 있어요.
const roArray: ReadonlyArray<string> = ["red", "green", "blue"];
TypeScript가 Array<Type>에 대해 Type[] 축약 문법을 제공하듯, ReadonlyArray<Type>에 대해서도 readonly Type[] 축약 문법을 제공해요.
// @errors: 2339
function doStuff(values: readonly string[]) {
// ^^^^^^^^^^^^^^^^^
// We can read from 'values'...
const copy = values.slice();
console.log(`The first value is ${values[0]}`);
// ...but we can't mutate 'values'.
values.push("hello!");
}
마지막으로 한 가지 짚어 둘 점은, readonly 프로퍼티 수식어와 달리 일반 Array와 ReadonlyArray 사이의 할당 가능성은 양방향이 아니라는 거예요.
// @errors: 4104
let x: readonly string[] = [];
let y: string[] = [];
x = y;
y = x;
튜플 타입 (Tuple Types)
튜플 타입(tuple type) 은 정확히 몇 개의 요소를 포함하는지, 그리고 특정 위치에 정확히 어떤 타입이 있는지를 아는 또 다른 종류의 Array 타입이에요.
type StringNumberPair = [string, number];
// ^^^^^^^^^^^^^^^^
여기서 StringNumberPair는 string과 number의 튜플 타입이에요. ReadonlyArray처럼 런타임에서의 표현은 없지만, TypeScript에게는 중요해요. 타입 시스템에서 StringNumberPair는 0 인덱스에 string이, 1 인덱스에 number가 있는 배열을 설명합니다.
function doSomething(pair: [string, number]) {
const a = pair[0];
// ^?
const b = pair[1];
// ^?
// ...
}
doSomething(["hello", 42]);
요소 개수를 넘어서 인덱싱하려고 하면 에러를 보게 돼요.
// @errors: 2493
function doSomething(pair: [string, number]) {
// ...
const c = pair[2];
}
자바스크립트의 배열 구조 분해를 사용해 튜플을 구조 분해할 수도 있어요.
function doSomething(stringHash: [string, number]) {
const [inputString, hash] = stringHash;
console.log(inputString);
// ^?
console.log(hash);
// ^?
}
튜플 타입은 규칙(convention)에 크게 의존하는 API에서 유용합니다. 각 요소의 의미가 "명백한" 경우죠. 이는 구조 분해할 때 변수를 원하는 대로 이름 지을 수 있는 유연성을 줍니다. 위 예시에서 요소
0과1을 원하는 어떤 이름으로도 지을 수 있었어요.다만 모든 사용자가 '명백함'에 대해 같은 관점을 가진 건 아니므로, 설명적인 프로퍼티 이름을 가진 객체를 쓰는 것이 여러분의 API에는 더 나을지 다시 고민해 볼 가치가 있어요.
이런 길이 검사 외에, 단순한 튜플 타입은 특정 인덱스에 대해 프로퍼티를 선언하고 숫자 리터럴 타입으로 length를 선언하는 버전의 Array 타입들과 동일해요.
interface StringNumberPair {
// specialized properties
length: 2;
0: string;
1: number;
// Other 'Array<string | number>' members...
slice(start?: number, end?: number): Array<string | number>;
}
또 관심 있을 만한 것은, 튜플은 요소 타입 뒤에 물음표(?)를 써서 선택적 프로퍼티를 가질 수 있다는 점이에요. 선택적 튜플 요소는 마지막에만 올 수 있고, length의 타입에도 영향을 줍니다.
type Either2dOr3d = [number, number, number?];
function setCoordinate(coord: Either2dOr3d) {
const [x, y, z] = coord;
// ^?
console.log(`Provided coordinates had ${coord.length} dimensions`);
// ^?
}
튜플은 또한 배열/튜플 타입이어야 하는 나머지(rest) 요소를 가질 수 있어요.
type StringNumberBooleans = [string, number, ...boolean[]];
type StringBooleansNumber = [string, ...boolean[], number];
type BooleansStringNumber = [...boolean[], string, number];
StringNumberBooleans는 첫 두 요소가 각각string과number인데, 그 뒤에boolean을 몇 개든 가질 수 있는 튜플을 설명해요.StringBooleansNumber는 첫 요소가string이고, 그다음에boolean을 몇 개든 가진 뒤number로 끝나는 튜플을 설명해요.BooleansStringNumber는 시작 요소가boolean을 몇 개든 갖고string다음number로 끝나는 튜플을 설명해요.
나머지 요소를 가진 튜플은 정해진 "길이"가 없어요. 단지 서로 다른 위치에 잘 알려진 요소들의 집합이 있을 뿐이죠.
type StringNumberBooleans = [string, number, ...boolean[]];
// ---cut---
const a: StringNumberBooleans = ["hello", 1];
const b: StringNumberBooleans = ["beautiful", 2, true];
const c: StringNumberBooleans = ["world", 3, true, false, true, false, true];
선택적 요소와 나머지 요소가 왜 유용할까요? 음, TypeScript가 튜플을 파라미터 목록과 대응시킬 수 있게 해주기 때문이에요. 튜플 타입은 나머지 파라미터와 인자(rest parameters and arguments)에 사용될 수 있어서, 다음 코드는
function readButtonInput(...args: [string, number, ...boolean[]]) {
const [name, version, ...input] = args;
// ...
}
기본적으로 이 코드와 동일해요.
function readButtonInput(name: string, version: number, ...input: boolean[]) {
// ...
}
나머지 파라미터로 가변 개수의 인자를 받고 싶은데, 최소 개수의 요소는 필요하고 중간 변수는 도입하고 싶지 않을 때 이게 편리해요.
readonly 튜플 타입
튜플 타입에 대한 마지막 참고 사항 — 튜플 타입에는 readonly 변형이 있고, 배열 축약 문법처럼 앞에 readonly 수식어를 붙여 지정할 수 있어요.
function doSomething(pair: readonly [string, number]) {
// ^^^^^^^^^^^^^^^^^^^^^^^^^
// ...
}
짐작하셨듯이 TypeScript에서는 readonly 튜플의 어떤 프로퍼티에도 쓰는 것이 허용되지 않아요.
// @errors: 2540
function doSomething(pair: readonly [string, number]) {
pair[0] = "hello!";
}
튜플은 대부분의 코드에서 생성된 뒤 수정되지 않고 남는 경향이 있어요. 그래서 가능하면 타입을 readonly 튜플로 표기하는 것이 좋은 기본값이에요. const 단언(assertion)을 가진 배열 리터럴이 readonly 튜플 타입으로 추론된다는 점에서도 이는 중요해요.
// @errors: 2345
let point = [3, 4] as const;
function distanceFromOrigin([x, y]: [number, number]) {
return Math.sqrt(x ** 2 + y ** 2);
}
distanceFromOrigin(point);
여기서 distanceFromOrigin은 요소를 수정하지 않지만, 변경 가능한(mutable) 튜플을 기대해요. point의 타입이 readonly [3, 4]로 추론되었기 때문에, [number, number]와 호환되지 않아요. 그 타입은 point의 요소가 변경되지 않을 것이라고 보장할 수 없으니까요.
더 알아보기 (Learn more)
- TypeScript 핸드북 - Everyday Types에서 객체 타입의 기초를 다시 살펴보세요.
- Mapped Types에서 매핑 수식어로
readonly를 다루는 법을 확인해 보세요. - Functions의 제네릭 함수와 나머지 파라미터 부분을 복습해 보세요.