객체 타입

객체 타입 (Object Types)

JavaScript에서는 데이터를 묶고 주고받을 때 기본적으로 객체를 사용해요. TypeScript에서는 그 객체의 모양을 객체 타입(object type) 으로 나타내요. 객체 타입은 두 가지 방식으로 적을 수 있는데, 하나는 이름 없이 바로 적는 익명 방식이고, 다른 하나는 interface나 type alias로 이름을 붙이는 방식이에요.

출처: TypeScript 공식문서

본문

익명이라면 이렇게 함수 파라미터 자리에 바로 객체 타입을 적어요.

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) 프로퍼티를 가진 객체를 받는 함수예요. typeinterface의 일상적인 문법을 한눈에 보고 싶다면 cheat-sheets를 참고하면 돼요.

프로퍼티 수식자 (Property Modifiers)

객체 타입의 각 프로퍼티는 타입뿐 아니라 '선택 사항인지', '값을 쓸 수 있는지'까지 몇 가지를 더 지정할 수 있어요.

선택적 프로퍼티 (Optional Properties)

자주 마주치는 상황 중 하나가, 객체에 어떤 프로퍼티가 있을 수도 있고 없을 수도 있는 경우예요. 이럴 땐 프로퍼티 이름 끝에 물음표(?)를 붙여서 선택적(optional) 프로퍼티로 표시하면 돼요.

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 });

여기서 xPosyPos는 둘 다 선택적이에요. 둘 중 하나만 줘도 되고 둘 다 줘도 되니, 위의 네 호출은 전부 유효해요. 선택적으로 표시했다는 건 "값이 설정된다면 그 타입이어야 한다"는 뜻이지, 값이 무조건 있어야 한다는 뜻은 아니에요.

이 프로퍼티들을 읽을 수도 있어요. 다만 strictNullChecks가 켜져 있으면 TypeScript가 "이건 undefined일 수도 있어"라고 알려줘요.

function paintShape(opts: PaintOptions) {
  let xPos = opts.xPos;
  let yPos = opts.yPos;
  // ...
}

JavaScript에서는 프로퍼티가 설정되지 않았어도 접근할 수 있고, 그 값은 그냥 undefined예요. 그래서 undefined인지만 확인해서 따로 처리해 주면 돼요.

function paintShape(opts: PaintOptions) {
  let xPos = opts.xPos === undefined ? 0 : opts.xPos;
  let yPos = opts.yPos === undefined ? 0 : opts.yPos;
  // ...
}

이렇게 "값이 지정되지 않았을 때 기본값을 정해주는" 패턴은 워낙 흔해서 JavaScript에 아예 전용 문법이 있어요. 파라미터 자리에서 기본값을 바로 지정할 수 있죠.

function paintShape({ shape, xPos = 0, yPos = 0 }: PaintOptions) {
  console.log("x coordinate at", xPos);
  console.log("y coordinate at", yPos);
  // ...
}

여기서는 paintShape의 파라미터에 구조 분해 할당을 쓰고, xPosyPos기본값을 지정했어요. 이제 paintShape 안에서는 xPosyPos가 무조건 존재하지만, 호출하는 쪽 입장에서는 여전히 선택적이에요.

한 가지 주의할 점은, 구조 분해 패턴 안에서는 타입 애너테이션을 적을 방법이 없다는 거예요. 아래 문법이 JavaScript에서 이미 다른 의미를 갖고 있기 때문이죠.

function draw({ shape: Shape, xPos: number = 100 /*...*/ }) {
  render(shape);
  render(xPos);
}

객체 구조 분해 패턴에서 shape: Shape는 "shape 프로퍼티를 가져와서 지역 변수 Shape로 다시 정의한다"는 의미예요. 마찬가지로 xPos: number는 파라미터의 xPos 값을 바탕으로 number라는 이름의 변수를 만드는 거죠.

readonly 프로퍼티

프로퍼티에는 readonly도 붙일 수 있어요. 런타임 동작은 전혀 바꾸지 않지만, 타입 검사 단계에서 그 프로퍼티에 값을 다시 쓸 수 없게 막아줘요.

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)이라는 뜻은 아니에요. 내부 내용을 바꾼다는 의미가 아니라, 그 프로퍼티 자체에 다시 값을 쓸 수 없을 뿐이에요.

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'

readonly 애트리뷰트를 제거하고 싶다면 매핑 수식자(mapping modifiers)를 쓰면 돼요.

인덱스 시그니처 (Index Signatures)

때로는 타입의 프로퍼티 이름을 미리 전부 알 수는 없지만, 값의 모양은 알고 있을 때가 있어요. 그럴 땐 인덱스 시그니처로 가능한 값들의 타입을 설명할 수 있어요.

interface StringArray {
  [index: number]: string;
}

const myArray: StringArray = getStringArray();
const secondItem = myArray[1];

위의 StringArray 인터페이스는 인덱스 시그니처를 갖고 있어요. 이 시그니처는 "StringArraynumber로 인덱싱하면 string을 돌려준다"고 선언하는 거예요.

인덱스 시그니처에 쓸 수 있는 타입은 제한돼 있어요. string, number, symbol, 템플릿 문자열 패턴, 그리고 이것들로만 이루어진 유니언 타입이 그 전부예요.

인덱서를 여러 타입으로 지원하는 것도 가능해요. 다만 number 인덱서와 string 인덱서를 함께 쓸 때는, 숫자 인덱서가 돌려주는 타입이 문자열 인덱서가 돌려주는 타입의 하위 타입(subtype)이어야 해요. 이유가 있어요. number로 인덱싱하면 JavaScript가 실제로는 그 숫자를 string으로 바꿔서 객체에 접근하거든요. 즉 100(number)으로 인덱싱하는 것과 "100"(string)으로 인덱싱하는 것이 같다는 뜻이라, 둘은 일관되어야 해요.

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.propertyobj["property"]로도 접근 가능하다고 선언하기 때문이에요. 아래 예시에서 name의 타입이 문자열 인덱스의 타입과 맞지 않아서 타입 체커가 에러를 내줘요.

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로 만들어서 인덱스에 값을 할당하지 못하게 할 수 있어요.

interface ReadonlyStringArray {
  readonly [index: number]: string;
}

let myArray: ReadonlyStringArray = getReadOnlyStringArray();
myArray[2] = "Mallory";

인덱스 시그니처가 readonly라서 myArray[2]에는 값을 설정할 수 없어요.

초과 프로퍼티 검사 (Excess Property Checks)

객체가 어디서 어떻게 타입을 부여받는지에 따라 타입 시스템의 동작이 달라질 수 있어요. 대표적인 예가 초과 프로퍼티 검사예요. 객체를 만들어서 객체 타입에 할당할 때, 이 검사는 객체를 더 꼼꼼하게 검증해요.

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라고 적혀 있어요. 순수 JavaScript에서는 이런 게 조용히 넘어가 버려요.

사실 이 프로그램이 타입상 맞다고 볼 수도 있어요. width 프로퍼티는 호환되고, color 프로퍼티가 없는 것도 문제없고, 추가된 colour 프로퍼티는 무시해도 될 테니까요. 하지만 TypeScript는 이런 코드에 버그가 있을 거라고 판단해요. 객체 리터럴은 특별 대우를 받아서, 다른 변수에 할당하거나 인자로 넘길 때 초과 프로퍼티 검사를 받아요. 객체 리터럴이 "목표 타입(target type)"에 없는 프로퍼티를 갖고 있으면 에러가 나요.

let mySquare = createSquare({ colour: "red", width: 100 });

이 검사를 피하는 건 사실 아주 간단해요. 가장 쉬운 방법은 타입 단언(type assertion)을 쓰는 거예요.

let mySquare = createSquare({ width: 100, opacity: 0.5 } as SquareConfig);

다만 더 나은 접근은, 객체가 특별한 방식으로 쓰이는 추가 프로퍼티를 가질 수 있다고 확신한다면 문자열 인덱스 시그니처를 추가하는 거예요. SquareConfig가 위 타입의 colorwidth를 가지면서도, 다른 프로퍼티를 얼마든지 가질 수 있어야 한다면 이렇게 정의하면 돼요.

interface SquareConfig {
  color?: string;
  width?: number;
  [propName: string]: unknown;
}

이제 SquareConfig가 프로퍼티를 몇 개든 가질 수 있고, colorwidth가 아니라면 그 타입은 신경 쓰지 않겠다는 뜻이에요.

조금 의외일 수 있는 마지막 우회 방법은, 객체를 다른 변수에 먼저 할당하는 거예요. squareOptions를 할당할 때는 초과 프로퍼티 검사가 일어나지 않으니 컴파일러가 에러를 내지 않아요.

let squareOptions = { colour: "red", width: 100 };
let mySquare = createSquare(squareOptions);

이 우회 방법은 squareOptionsSquareConfig 사이에 공통 프로퍼티가 하나 있을 때만 동작해요. 이 예시에서는 width가 그 역할을 했죠. 그런데 변수에 공통 객체 프로퍼티가 하나도 없으면 실패해요.

let squareOptions = { colour: "red" };
let mySquare = createSquare(squareOptions);

위 같은 단순한 코드에서는 이런 검사를 "우회"하려고 애쓰지 않는 게 좋아요. 메서드를 갖고 상태를 보관하는 더 복잡한 객체 리터럴에서는 이런 테크닉이 필요할 수 있지만, 초과 프로퍼티 에러의 대부분은 사실은 진짜 버그예요. 옵션 객체(option bag) 같은 데서 초과 프로퍼티 검사 문제를 만나고 있다면, 타입 선언을 다시 다듬어야 할 때라는 신호로 받아들이는 게 좋아요. colorcolour 프로퍼티를 가진 객체를 createSquare에 넘겨도 괜찮다면, SquareConfig의 정의를 그에 맞게 고쳐주는 게 맞아요.

타입 확장 (Extending Types)

다른 타입보다 좀 더 구체적인 버전인 타입을 만들어야 할 때가 꽤 흔해요. 예를 들어 미국에서 편지나 소포를 보내는 데 필요한 필드를 담은 BasicAddress 타입이 있다고 볼게요.

interface BasicAddress {
  name?: string;
  street: string;
  city: string;
  country: string;
  postalCode: string;
}

어떤 상황에서는 그것만으로 충분하지만, 주소 건물에 여러 유닛이 있으면 유닛 번호가 필요해지죠. 그럴 때 AddressWithUnit을 만들 수 있어요.

interface AddressWithUnit {
  name?: string;
  unit: string;
  street: string;
  city: string;
  country: string;
  postalCode: string;
}

이대로도 동작은 해요. 다만 문제는, 변경이 순전히 추가(additive)뿐인데 BasicAddress의 다른 필드를 전부 반복해서 적어야 한다는 거예요. 대신 원래 BasicAddress 타입을 확장해서 AddressWithUnit에만 고유한 새 필드만 추가하면 돼요.

interface BasicAddress {
  name?: string;
  street: string;
  city: string;
  country: string;
  postalCode: string;
}

interface AddressWithUnit extends BasicAddress {
  unit: string;
}

interfaceextends 키워드는 다른 이름 있는 타입의 멤버를 사실상 복사해 오면서, 원하는 새 멤버를 얼마든지 추가할 수 있게 해줘요. 이는 타입 선언의 보일러플레이트를 줄이는 데도 유용하고, 같은 프로퍼티에 대한 여러 선언이 서로 연관되어 있음을 뜻을 전달하는 데도 도움이 돼요. AddressWithUnitstreet를 다시 적을 필요가 없었고, streetBasicAddress에서 왔다는 사실만으로 두 타입이 어떻게든 연관되어 있음을 읽는 사람이 알 수 있게 돼요.

interface는 여러 타입에서 동시에 확장할 수도 있어요.

interface Colorful {
  color: string;
}

interface Circle {
  radius: number;
}

interface ColorfulCircle extends Colorful, Circle {}

const cc: ColorfulCircle = {
  color: "red",
  radius: 42,
};

교차 타입 (Intersection Types)

interfaceextends로 다른 타입에서 새 타입을 만들어냈죠. TypeScript에는 이와 다른 방식으로, 주로 기존 객체 타입을 결합하는 데 쓰는 교차 타입(intersection type) 이라는 구성 요소도 있어요.

교차 타입은 & 연산자로 정의해요.

interface Colorful {
  color: string;
}
interface Circle {
  radius: number;
}

type ColorfulCircle = Colorful & Circle;

여기서 ColorfulCircle을 교차해서, 두 타입의 모든 멤버를 가진 새 타입을 만들어냈어요.

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. 교차 (Interface Extension vs. Intersection)

방금 타입을 결합하는 두 가지 방식, 즉 서로 비슷하지만 미묘하게 다른 방식을 봤어요. 인터페이스로는 extends 절을 써서 다른 타입을 확장했고, 교차 타입으로 비슷한 일을 하면서 그 결과를 type alias로 이름 붙일 수도 있었죠. 둘 사이의 핵심 차이는 충돌을 어떻게 처리하느냐이고, 그 차이가 보통 인터페이스와 (교차 타입의) type alias 중 무엇을 고를지 결정하는 주요 이유 중 하나가 돼요.

같은 이름으로 인터페이스를 정의하면, 프로퍼티가 호환될 경우 TypeScript가 그것들을 병합하려고 해요. 프로퍼티가 호환되지 않으면(같은 프로퍼티 이름인데 타입이 다르다면) 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;

이 경우 Staffname 프로퍼티가 string이면서 동시에 number여야 한다는 요구를 하게 되고, 결국 그 프로퍼티가 never 타입이 되어버려요.

제네릭 객체 타입 (Generic Object Types)

어떤 값이든 담을 수 있는 Box 타입을 상상해 볼게요. string이든 numberGiraffe든 뭐든 말이에요.

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 타입을 일일이 만드는 거예요.

interface NumberBox {
  contents: number;
}

interface StringBox {
  contents: string;
}

interface BooleanBox {
  contents: boolean;
}

그런데 그러면 이 타입들로 동작하는 함수를 각각 만들거나 오버로드해야 해요.

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;
}

보일러플레이트가 정말 많아요. 게다가 나중에 새 타입과 새 오버로드를 추가해야 할 수도 있고요. box 타입들끼리, 오버로드들끼리 모두 사실상 똑같아서 답답한 상황이죠.

대신 타입 파라미터(type parameter) 를 선언하는 제네릭 Box 타입을 만들 수 있어요.

interface Box<Type> {
  contents: Type;
}

이걸 "TypeBoxcontentsType 타입인 것"이라고 읽으면 돼요. 나중에 Box를 참조할 때는 Type 자리에 타입 인자(type argument) 를 넣어줘야 해요.

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;

BoxType을 아무거나로 치환할 수 있어서 재사용성이 높아요. 새 타입을 위한 box가 필요해도 새 Box 타입을 선언할 필요가 없다는 뜻이에요(물론 원하면 선언해도 되지만요).

interface Box<Type> {
  contents: Type;
}

interface Apple {
  // ....
}

// Same as '{ contents: Apple }'.
type AppleBox = Box<Apple>;

이렇게 하면 제네릭 함수를 쓰는 방식으로 오버로드를 아예 피할 수도 있어요.

function setContents<Type>(box: Box<Type>, newContents: Type) {
  box.contents = newContents;
}

type alias도 제네릭이 될 수 있다는 점을 짚고 넘어갈게요. 우리가 새로 만든 Box<Type> 인터페이스를:

interface Box<Type> {
  contents: Type;
}

type alias로 정의할 수도 있었어요.

type Box<Type> = {
  contents: Type;
};

type alias는 인터페이스와 달리 객체 타입보다 더 많은 것을 표현할 수 있으니, 다른 종류의 제네릭 헬퍼 타입을 만드는 데도 쓸 수 있어요.

type OrNull<Type> = Type | null;

type OneOrMany<Type> = Type | Type[];

type OneOrManyOrNull<Type> = OrNull<OneOrMany<Type>>;

type OneOrManyOrNullStrings = OneOrManyOrNull<string>;

type alias에 대해서는 잠시 후에 다시 돌아올게요.

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 자체도 제네릭 타입이에요.

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;

  // ...
}

최신 JavaScript에는 이 외에도 제네릭인 데이터 구조가 많아요. Map<K, V>, Set<T>, Promise<T> 같은 것들이죠. 이것이 의미하는 바는, Map, Set, Promise 같은 것들은 그 동작 방식 덕분에 어떤 타입 집합과도 함께 동작할 수 있다는 거예요.

ReadonlyArray 타입

ReadonlyArray는 바뀌면 안 되는 배열을 설명하는 특별한 타입이에요.

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는 쓸 수 있는 생성자가 없어요.

new ReadonlyArray("red", "green", "blue");

대신 일반 ArrayReadonlyArray에 할당할 수 있어요.

const roArray: ReadonlyArray<string> = ["red", "green", "blue"];

TypeScript가 Array<Type>Type[]로 줄여 쓰는 문법을 제공하는 것처럼, ReadonlyArray<Type>readonly Type[]로 줄여 쓰는 문법이 있어요.

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 수식자와 달리 일반 ArrayReadonlyArray 사이의 할당 가능성(assignability)은 양방향이 아니라는 거예요.

let x: readonly string[] = [];
let y: string[] = [];

x = y;
y = x;

튜플 타입 (Tuple Types)

튜플 타입(tuple type) 은 또 다른 종류의 Array 타입인데, 요소를 정확히 몇 개 가지는지, 그리고 특정 위치에 정확히 어떤 타입이 오는지를 알고 있어요.

type StringNumberPair = [string, number];

여기서 StringNumberPairstringnumber의 튜플 타입이에요. ReadonlyArray처럼 런타임에는 아무런 표현이 없지만, TypeScript에게는 의미가 있어요. 타입 시스템 관점에서 StringNumberPair0 인덱스에 string이, 1 인덱스에 number가 있는 배열을 설명해요.

function doSomething(pair: [string, number]) {
  const a = pair[0];
  const b = pair[1];
  // ...
}

doSomething(["hello", 42]);

요소 수를 넘어서 인덱싱하려고 하면 에러가 나요.

function doSomething(pair: [string, number]) {
  // ...

  const c = pair[2];
}

이런 튜플도 JavaScript의 배열 구조 분해를 사용해서 분해할 수 있어요.

function doSomething(stringHash: [string, number]) {
  const [inputString, hash] = stringHash;

  console.log(inputString);

  console.log(hash);
}

튜플 타입은 관례가 강한 API에서 유용한데, 각 요소의 의미가 "뻔할" 때 그렇죠. 구조 분해할 때 변수 이름을 우리가 원하는 대로 지을 수 있는 유연함을 주거든요. 위 예시에서 우리는 요소 01을 원하는 이름으로 지었어요. 다만 모든 사용자가 똑같이 "뻔하다"고 생각하지는 않으니, 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 element)도 가질 수 있는데, 그 타입은 배열/튜플 타입이어야 해요.

type StringNumberBooleans = [string, number, ...boolean[]];
type StringBooleansNumber = [string, ...boolean[], number];
type BooleansStringNumber = [...boolean[], string, number];
  • StringNumberBooleans는 처음 두 요소가 각각 stringnumber이고, 뒤에 boolean이 몇 개든 올 수 있는 튜플을 설명해요.
  • StringBooleansNumber는 첫 요소가 string이고, 그다음에 boolean이 몇 개든 오며, 마지막에 number로 끝나는 튜플이에요.
  • BooleansStringNumber는 시작 요소가 boolean 몇 개든이고, string 다음에 number로 끝나는 튜플이에요.

나머지 요소가 있는 튜플은 정해진 "길이"가 없어요. 다만 서로 다른 위치에 잘 알려진 요소들이 있을 뿐이에요.

const a: StringNumberBooleans = ["hello", 1];
const b: StringNumberBooleans = ["beautiful", 2, true];
const c: StringNumberBooleans = ["world", 3, true, false, true, false, true];

선택적 요소와 나머지 요소가 왜 유용할까요? 그것들이 TypeScript가 튜플을 파라미터 목록과 대응시키게 해주기 때문이에요. 튜플 타입은 나머지 파라미터와 인자에서 쓸 수 있어서, 아래 코드는:

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]) {
  // ...
}

예상할 수 있듯이, readonly 튜플의 어떤 프로퍼티든 값 쓰기는 TypeScript에서 허용되지 않아요.

function doSomething(pair: readonly [string, number]) {
  pair[0] = "hello!";
}

튜플은 대부분의 코드에서 만들어지고 나서 수정되지 않고 그대로 쓰이곤 해요. 그래서 가능하면 타입을 readonly 튜플로 지정하는 게 좋은 기본값이에요. 또, const 단언이 붙은 배열 리터럴은 readonly 튜플 타입으로 추론되기 때문에 이 점이 중요해지기도 해요.

let point = [3, 4] as const;

function distanceFromOrigin([x, y]: [number, number]) {
  return Math.sqrt(x ** 2 + y ** 2);
}

distanceFromOrigin(point);

여기서 distanceFromOrigin은 요소를 전혀 수정하지 않지만, 변경 가능한 튜플을 기대해요. 그런데 point의 타입이 readonly [3, 4]로 추론되어서, [number, number]와 호환되지 않아요. [number, number]point의 요소가 변경되지 않을 거라고 보장할 수 없기 때문이에요.

더 알아보기