dimohy
디모이 블로그

디모이 블로그

TypeScript 배우기 - 6. 객체 유형

dimohy's photo
dimohy
·Jul 20, 2022·

11 min read

JavaScript는 데이터를 그룹화 해서 전달할 수 있는 수단으로 객체 유형을 제공합니다. 앞에서 살펴 본 것처럼 다음의 무명 형태로 표현할 수 있습니다.

function greet(person: { name: string; age: number }) {
  return "Hello " + person.name;
}

이것을 type으로 별칭으로 표현할 수도 있고요,

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

function greet(person: Person) {
  return "Hello " + person.name;
}

인터페이스로도 표현할 수 있습니다.

interface Person {
  name: string;
  age: number;
}

function greet(person: Person) {
  return "Hello " + person.name;
}

위의 세가지 표현은 name이라는 string 유형 속성과 age라는 number 유형 속성을 가진 객체 유형을 인자로 받는 함수입니다.

속성 수식어

TypeScript는 속성이 선택사항인지, 속성이 읽기 전용인지 등의 속성 수식어를 제공합니다.

선택적 속성

속성 이름에 ?을 주면 선택적 속성이 됩니다.

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 속성은 생략해도 되는 속성이 됩니다. 유형은 number | undefined 유니언 유형이 됩니다.

image.png

xPosyPosundefined가 될 수 있으므로 undefined일 때 별도의 처리를 해야 할 수 있습니다.

image.png

다음처럼 함수 인자에 초기 값을 줘서 위의 수행을 할 수 도 있습니다.

image.png

위의 코드는 paintShape함수에 구조 해재 패턴을 사용해서 xPosyPos의 기본 값을 제공했습니다.

현재로서는 구조 해제 패턴에 유형 주석을 배치할 수 없습니다. 다음의 구문은 JavaScript에서 다른 의미이기 때문입니다.

function draw({ shape: Shape, xPos: number = 100 /*...*/ }) {
  render(shape);
         ~~~~~
> Cannot find name 'shape'. Did you mean 'Shape'?
  render(xPos);
         ~~~~
> Cannot find name 'xPos'.
}

JavaScript의 구조 해제 패턴에서 shape: ShapeshapeShape 유형이라는 의미가 아니라 shape 속성이 함수 로컬 변수로 Shape로 잡겠다는 의미입니다. xPos: number도 마찬가지로 해석하기 때문에 위의 코드처럼 사용할 수 없습니다.

readonly 속성

readonly 속성은 TypeScript에서 읽기 전용이라는 의미를 부여합니다. JavaScript의 런타임 동작에는 영향을 주지 않지만 읽기 전용이라고 표시된 속성은 TypeScript의 유형 검사에서 수정할 경우 오류로 판단합니다.

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";
      ~~~~
> Cannot assign to 'prop' because it is a read-only property.
}

하지만 readonly 수식어를 썼다고 속성의 내부 내용을 변경할 수 없다는 것은 아닙니다. 속성 자체를 다시 설정할 수 없다는 의미입니다.

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 = {
       ~~~~~~~~
> Cannot assign to 'resident' because it is a read-only property.
    name: "Victor the Evictor",
    age: 42,
  };
}

readonly의 동작을 좀 더 살펴볼 필요가 있습니다. TypeScript는 두 유형의 속성이 호환되는지 살필 때 readonly를 고려하지 않으므로 읽기 전용 속성도 별칭을 통해 변경할 수 있습니다.

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'

위의 코드를 보시면 PersonReadonlyPerson으로 수정 가능한 인터페이스와 수정 불가능한 인터페이스를 정의했고, 먼저 수정 가능한 인터페이스로 writablePerson 객체를 생성한 후 수정 불가능한 인터페이스의 readonlyPerson 변수에 writablePerson 객체를 대입했습니다. 이후 writablePerson.age++ 코드에 의해 readonlyPerson.age의 값 역시 증가함을 확인할 수 있습니다.

인덱스 서명

배열의 인덱스 및 반환 유형을 다음의 코드처럼 표현할 수 있습니다.

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

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

TypeScript에서는 StringArray 인터페이스로 만들어진 객체는 배열의 요소 에 접근할 때 number 유형의 인덱스로 접근하고 그 반환형이 string 유형인 것 만을 인정합니다.

TypeScript는 두가지 이상의 인덱서를 지원하기는 하지만 반환형은 반드시 숫자 인덱서에서 반환되는 유형과 같거나 상위 유형으로 강제합니다. 아닌 경우 오류가 발생합니다.

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;
> 'number' index type 'Animal' is not assignable to 'string' index type 'Dog'.
  [x: string]: Dog;
}

문자열 인덱스 서명은 사전 패턴을 표현하는 강력한 방법이지만 모든 속성이 반환 유형과 같아야 합니다.

interface NumberDictionary {
  [index: string]: number;

  length: number; // ok
  name: string;
        ~~~~~~
> Property 'name' of type 'string' is not assignable to 'string' index type 'number'.
}

인덱스 서명의 반환형이 유니언 유형의 경우 가능합니다.

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";
> Index signature in type 'ReadonlyStringArray' only permits reading.

확장 유형

비슷하지만 좀 더 구체적인 유형을 만들어야 하는 이유는 매우 일상적으로 발생합니다. 다음의 기본 주소는,

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

다음의 단위가 포함되어야 하는 주소일 경우,

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

BasicAddress의 대부분의 속성과 동일하지만 반복해서 그 속성을 사용해야만 합니다. 이를 extends 키워드를 사용해 다음처럼 간단히 표현할 수 있습니다.

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

interface AddressWithUnit extends BasicAddress {
  unit: string;
}

AddressWithUnit 인터페이스는 unit 뿐만 아니라 BasicAddress의 속성 구성도 그대로 계승하게 됩니다.

다음처럼 여러 유형에서 확장할 수 도 있습니다.

interface Colorful {
  color: string;
}

interface Circle {
  radius: number;
}

interface ColorfulCircle extends Colorful, Circle {}

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

교차 유형

TypeScript에서는 교차 유형이라는 것도 제공합니다. 교차 유형은 기존 유형을 결합하는데 사용할 수 있습니다.

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

type ColorfulCircle = Colorful & Circle;
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 });
> Argument of type '{ color: string; raidus: number; }' is not assignable to parameter of type 'Colorful & Circle'.
>  Object literal may only specify known properties, but 'raidus' does not exist in type 'Colorful & Circle'. Did you mean to write 'radius'?

인터페이스 vs 교차

인터페이스와 교차는 다른 방식으로 유사한 결과를 만들 수 있습니다. 인터페이스와 교차의 차이점은 충돌을 처리하는 방법이며, 이 충돌 처리에 따라 인터페이스와 교차 유형 중 하나를 선택할 수 있습니다.

제네릭 객체 유형

어떠한 내용 유형인지 상관없는 Box 인터페이스는 다음처럼 표현할 수 있습니다.

interface Box {
  contents: any;
}

하지만 any 유형이므로 TypeScript의 유형 검사 및 편집기 기능을 이용할 수 없습니다. 유형을 알지 못할 경우 unknown 유형으로 TypeScript의 유형 검사 시점에서 이 객체를 잘못 사용하는 것을 방지할 수 있습니다.

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

하지만 유형을 특정할 수 있는 경우 각각 인터페이스로 해당 유형을 기술한 후 함수 오버로드로 이를 표현할 수 있습니다.

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

하지만 복잡하죠. 이를 제네릭을 이용해 다음처럼 간단히 표현할 수 있습니다.

interface Box<Type> {
  contents: Type;
}

이제 이 제네릭 인터페이스의 사용은 다음처럼 할 수 있습니다.

let box: Box<string>;

결국에 StringBox와 동일하게 처리가 가능하게 됩니다.

image.png

제네릭을 사용하면 string 뿐만 아니라 다양한 유형으로 대체할 수 있다는 점이 장점입니다.

interface Box<Type> {
  contents: Type;
}

interface Apple {
  // ....
}

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

이제 setContents()함수를 다음처럼 개선할 수 있습니다.

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

유형 별칭 type에도 제네릭을 사용할 수 있습니다.

interface Box<Type> {
  contents: Type;
}

이것을 type을 사용해서 다음처럼 쓸 수 있습니다.

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

하지만 유형 별칭은 인터페이스와 달리 개체 유형 이상을 설명할 수 있으므로 다른 종류의 도우미 유형을 작성하는데 사용할 수 있습니다.

image.png

Array 유형

제네릭 객체 유형은 포함된 요소와 독립적으로 작동하는 일종의 컨테이너 유형일 수 있습니다. 다양한 데이터 유형에서 재사용할 수 있도록 데이터 구조가 이런 방식으로 작동하는게 이상적입니다.

실제로 numer[], 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"));

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>와 같이 제네릭인 다른 데이터 구조도 제공합니다. 이것의 의미는 제네릭 유형의 유형 집합과 함께 작동할 수 있다는 점입니다.

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!");
         ~~~~
> Property 'push' does not exist on type 'readonly string[]'.
}

속성에 대한 readonly 수식어와 마찬가지로 배열의 내용이 변경될 수 없음을 나타내는 도구입니다. 이렇게 표현하면 이 배열이 어떠한 함수에 전달되어도 변경되는 것을 걱정할 필요가 없습니다.

ReadonlyArray 유형은 new로 생성할 수 없습니다.

new ReadonlyArray("red", "green", "blue");
    ~~~~~~~~~~~~~
> 'ReadonlyArray' only refers to a type, but is being used as a value here.

대신 다음과 같이 사용합니다.

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

TyoeScruot 가 Array<Type>의 약식 구문인 Type[]을 제공하는 것처럼 마찬가지로 readonly Type[]ReadonlyArray<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!");
         ~~~~
> Property 'push' does not exist on type 'readonly string[]'.
}

하지만 주의할 점은readonly 수식어와 달리 할당 가능성은 Array와 ReadonlyArray간 양방향이 아닙니다.

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

x = y;
y = x;
~
> The type 'readonly string[]' is 'readonly' and cannot be assigned to the mutable type 'string[]'.

튜플 유형

튜플 유형은 포함된 요소의 수와 특정 위치에 포함된 유형을 정확히 알고 있는 또다른 종류의 Array 유형입니다.

type StringNumberPair = [string, number];

여기서 StringNumberPairstirngnumber 유형의 튜플 유형입니다. 물론 JavaScript의 런타임 시점에서는 이런 표현이 의미가 없지만 TypeScript에서는 배열의 요소 수와 포함된 유형에 대한 정보가 있으므로 중요합니다.

function doSomething(pair: [string, number]) {
  const a = pair[0];

const a: string
  const b = pair[1];

const b: number
  // ...
}

doSomething(["hello", 42]);

이제 튜플 유형에서 벗어난 값을 대입하려 할 때 오류가 발생합니다.

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

  const c = pair[2];
                 ~
> Tuple type '[string, number]' of length '2' has no element at index '2'.
}

또한 튜플 유형은 구조 해제를 통해서도 유용합니다.

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

  console.log(inputString);

const inputString: string

  console.log(hash);

const hash: number
}

튜플 유형은 규약 기반 API에서 유용합니다.

또한 아래와 같이도 표현 가능합니다.

interface StringNumberPair {
  // specialized properties
  length: 2;
  0: string;
  1: number;

  // Other 'Array<string | number>' members...
  slice(start?: number, end?: number): Array<string | number>;
}

튜플 유형은 선택적 속성을 가질 수 도 있습니다. 선택적 속성은 튜블 요소 끝에만 가질 수 있습니다.

![image.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1658286498064/VIolvYG2f.png align="left")

선택적 요소에 의해 길이가 2 | 3의 리터럴 유니언 유형이라는 것이 흥미롭네요.

튜플은 또한 나머지 요소를 다음처럼 표현할 수 있습니다.

type StringNumberBooleans = [string, number, ...boolean[]];
type StringBooleansNumber = [string, ...boolean[], number];
type 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 수식어를 사용할 수 있습니다.

function doSomething(pair: readonly [string, number]) {
  pair[0] = "hello!";
       ~
> Cannot assign to '0' because it is a read-only property.
}

튜플은 대부분은 생성된 이후 수정되지 않아야 하는 목적에 사용되므로 대부분의 경우 읽기 전용 튜플을 사용하는 것이 좋습니다. 이는 as const가 읽기 전용 튜플 유형으로 유추 된다는 점에서도 중요합니다.

let point = [3, 4] as const;

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

distanceFromOrigin(point);
                   ~~~~~
> Argument of type 'readonly [3, 4]' is not assignable to parameter of type '[number, number]'.
>   The type 'readonly [3, 4]' is 'readonly' and cannot be assigned to the mutable type '[number, number]'.

정리

오늘은 핸드북의 Object Types에 대해 알아보았습니다. 다음시간에는 핸드북의 Type Manipulation을 순서대로 살펴보도록 하겠습니다.

 
Share this