타입스크립트의 타이핑

  • 자바스크립트는 덕 타이핑(duck typing) 기반이고 타입스크립트가 이를 모델링하기 위해 구조적 타이핑을 사용합니다.
    • 타입스크립트가 구조적 타이핑을 도입한 이유는 동적 타입 언어인 자바스크립트를 기반으로 하기 때문입니다.
    • Java 기반의 객체지향에서는 명목적 타이핑을 사용합니다.

구조적 타이핑이란?

  • 구조적 타이핑은 값 자체의 타입보다는 값이 가진 내부 구조에 기반해서 타입 호환성을 검사한다.
  • 좋든 싫든 타입은 열려있습니다.
  • 타입스크립트의 클래스 역시 구조적 타이핑 규칙을 따릅니다.
    • 클래스의 인스턴스가 Java 기반의 객체지향과 다를 수 있습니다.
  • 어떤 인터페이스에 할당 가능한 값이라면 타입 선언에 명시적으로 나열된 속성들을 가지고 있을 겁니다. 타입은 봉인되어 있지 않습니다.
  • 추가 속성이 있으면 값의 집합은 더 작아집니다.
  • 반대로 유니온 타입이 있으면 값의 집합은 더 커집니다.

내부 구조만 같다면 타입은 동일한 것으로 판단합니다.

class ClassA {
  str: string;
  constructor(str: string) {
    this.str = str;
  }
}
const classA = new ClassA('instance of A');
const objectB: ClassA = { str: 'object literal' };

// ---

class A {
  clap() {
    console.log('A');
  }
}
class B {
  clap() {
    console.log('B');
  }
}
const C = {
  clap() {
    console.log('C');
  },
};

let a: A = new A();
a.clap();

a = new B();
a.clap();

a = C;
a.clap();

a = { clap: () => console.log('D') };
a.clap();

인터페이스의 이름이 달라도 내부 구조가 같다면 할당 가능합니다.

  • 이는 Java 기반의 객체지향과의 큰 차이점이며 우리가 구조적 타이핑을 잘 이해하고 사용해야 하는 이유 입니다.
interface Vector1D {
  x: number;
}

interface Point {
  x: number;
}

let p = v1; // Ok
v1 = p; // Ok

타입스크립트는 구조적 타이핑(덕 타이핑)을 사용하기 때문에, 값을 세밀하게 구분하지 못하는 경우가 있습니다

  • 값을 구분하기 위해 공식 명칭이 필요하다면 상표를 붙이는 것을 고려해야 합니다.
  • 상표 기법은 타입 시스템에서 동작하지만 런타임에 상표를 검사하는 것과 동일한 효과를 얻을 수 있습니다.
type Meters = number & { _brand: 'meters' };
type Seconds = number & { _brand: 'seconds' };

const meters = (m: number) => m as Meters;
const seconds = (s: number) => s as Seconds;

const oneKm = meters(1000); // 타입이 Meters
const oneMin = seconds(60); // 타입이 Seconds

상속을 받은 것과 나열한 것 역시 내부 구조가 같다면 동일한 것으로 판단 합니다.

interface Vector1D {
  x: number;
}

// Vector2D는 Vector1D의 서브타입입니다.
// Vector2D와 Vector2D2는 타입스크립트의 구조적 타이핑에서 동일합니다.
interface Vector2D extends Vector1D {
  y: number;
}
interface Vector2D2 {
  x: number;
  y: number;
}

// Vector3D는 Vector2D의 서브타입입니다.
// Vector3D와 Vector3D2는 타입스크립트의 구조적 타이핑에서 동일합니다.
interface Vector3D extends Vector2D {
  z: number;
}
interface Vector3D2 {
  x: number;
  y: number;
  z: number;
}

구조적 타이핑은 잉여 속성 체크에 한계가 있습니다.

  • 객체 리터럴 변수에 할당하거나 함수에 매개변수로 전달할 때 잉여 속성 체크가 수행됩니다.
  • 잉여 속성 체크는 오류를 찾는 효과적인 방법이지만, 타입스크립트 타입 체커가 수행하는 일반적인 구조적 할당 가능성 체크와 역할이 다릅니다. 할당의 개념을 정확히 알아야 잉여 속성 체크와 일반적인 구조적 할당 가능성 체크를 구분할 수 있습니다.
  • 잉여 속성 체크는 구조적 타이핑 시스템에서 허용되는 속성 이름의 오타 같은 실수를 잡는 데 효과적인 방법입니다. 선택적 필드를 포함하는 타입에 특히 유용한 반면, 적용 범위도 매우 제한적이며 오직 객체 리터럴에만 적용됩니다.
  • 잉여 속성 체크에는 한계가 있습니다. 임시 변수를 도입하면 잉여 속성 체크를 건너뛸 수 있다는 점을 기억해야 합니다.
interface Vector1D {
  x: number;
}

interface Vector2D extends Vector1D {
  y: number;
}

let v1: Vector1D = {
  x: 1,
};
let v2: Vector2D = {
  x: 1,
  y: 2,
};

v1 = v2; // OK(임시 변수를 도입하면 잉여 속성 체크를 건너뛸 수 있다)
v2 = v1; // Property 'y' is missing in type 'Vector1D' but required in type 'Vector2D'.(2741)

// ---

interface A {
  a: string;
  b: string;
}

const a: A = {
  a: 'a',
  b: 'b',
  c: 'c',
  // '{ a: string; b: string; c: string; }' 형식은 'A' 형식에 할당할 수 없습니다. 개체 리터럴은 알려진 속성만 지정할 수 있으며 'A' 형식에 'c'이(가) 없습니다.ts(2322)
};

const b = {
  a: 'a',
  b: 'b',
  c: 'c',
};

const c: A = b; // OK(임시 변수를 도입하면 잉여 속성 체크를 건너뛸 수 있다)

그렇다면 타입을 어떻게 사용해야 할까요?

  • 공통적인 것을 원본 모델로 만들고 이를 유틸리티 타입을 활용해서 파생된 타입을 사용합니다.
    • 타입스크립트는 절충하는 언어입니다.
    • 원본 타입과 유틸타입의 조합으로 타입이 많아지는 것을 방지합니다.

리액트에서 컴포넌트를 만드는 것처럼 타이핑을 해보겠습니다.

  • 원본 모델(인터페이스)과 타입 레이어의 분리합니다.
    • interface는 병합이 가능하다는 특징을 활용해 외부와의 소통에 활용합니다. 사용하는 곳에서 필요에 따라 유틸리티 타입 혹은 병합 성질을 활용해서 사용합니다.
    • type은 파일 내에서 interface를 활용해서 만들어서 사용합니다.
  • 원본 모델은 css-in-js나 custom hook에서 거의 같지만 조금 다르게 활용되는 경우가 많습니다. 보통 Component의 props에서는 optional인 값이 css-in-js로 넘어갈 때는 필수값이 됩니다. 이를 위해 타입을 2번 만드는 것이 아니라 원본 모델과 유틸리티 타입을 활용해 타입을 여러개 만들지 않고 유지보수성도 향상 시킬 수 있습니다.
// 원본 모델(외부와 소통 및 공개용)
export interface TextProps {
  value: string;
  placeholder: string;
  color: string;
  border: string;
}

// 내부 소모용
type TextPropsMustBe = Pick<TextProps, 'value' | 'border'>;
type TextPropsPartialType = Partial<TextProps> & TextPropsMustBe;
type TextContainerProps = Pick<TextProps, 'color'>;

// Component
const TextComponent: (props: TextPropsPartialType) => string = (props) => {
  const passedProps = useText(props);

  return TextContainer(passedProps);
};

// css-in-js
const TextContainer = (props: TextContainerProps) => `
  color: ${props.color};
`;

// custom hook
function useText(
  props: TextPropsPartialType,
): TextPropsMustBe & TextContainerProps {
  return {
    color: props.value || '#fff',
    value: props.value,
    border: props.border,
  };
}

// 실제 사용
TextComponent({ value: '', border: '1px solid black' });

참고

  • 마광휘님의 설명
    • 타입스크립트의 핵심 개념인 구조적 타입 시스템을 기반으로 설명하면, 타입스크립트에서 구조적 타입 시스템을 선택한 이유는 다음과 같습니다. interface는 가장 기저에 있는 객체의 모델을 나타냅니다. 다만, 자바스크립트는 타입으로 정의할 수 없는 다양한 형태가 런타임으로 동작될 수 있습니다. 그렇기에 런타임과 컴파일 타임 (사실 타입 체킹 타임 이겠죠?)이 다르고, 런타임에서 원하는 (허용해야하는) 경우와 오브젝트에서 은닉하고 캡슐화 해야하는 경우 등이 존재. 그러한 다양한 타입을 포용하려면 타입을 자유롭게 가공하고 만들 수 있도록 제공되어야 했습니다. 그렇기에 다양한 Utility Type과 그에따른 순수 객체를 위한 인터페이스를 분리하고 조합하여 Type Alias를 하여금 더 복잡한 타입을 나타낼 수 있도록 하였고, 궁극적인 지향점은, 아주 최소한의 모델을 만들고 최소한의 모델을 유연하게 타입으로 만들어서, 타입을 조합해 최소한의 타입으로 동작하도록 하는 것입니다. 또한 이 구조는 타입스크립트에서 지향하고 있는 “점진적 타이핑“과 자바스크립트의 “덕 타이핑” 관점에서도 충분히 먹히는 선택지기에 가게 되었죠. (출처: 타입스크립트 위키피디아 + 타입스크립트 깃헙 MR)

서브타입 파보기

extends에서의 관계

서브셋이 아닌 경우

// ERROR
interface A {
  a: string;
}

interface B extends A {
  // Interface 'B' incorrectly extends interface 'A'. Types of property 'a' are incompatible. Type 'number' is not assignable to type 'string'.(2430)
  a: number;
}

서브셋인 경우

// OK
interface A {
  a?: string;
}

interface B extends A {
  a: string;
}

서브셋이 아닌 경우2

// ERROR
interface A {
  a: string;
}

interface B extends A {
  // Interface 'B' incorrectly extends interface 'A'. Types of property 'a' are incompatible. Type 'string | undefined' is not assignable to type 'string'. Type 'undefined' is not assignable to type 'string'.(2430)
  a?: string;
}

선언 병합에서는?

같은 타입인 경우

// OK
interface A {
  a: string;
}

interface A {
  a: string;
}

서브셋이 아닌 경우

// ERROR
interface A {
  a: string;
  // All declarations of 'a' must have identical modifiers.(2687)
}

interface A {
  a: number;
  // All declarations of 'a' must have identical modifiers.(2687)
  // Subsequent property declarations must have the same type.  Property 'a' must be of type 'string | undefined', but here has type 'string'.(2717)
}

서브셋인 경우

// ERROR
interface A {
  a: string;
  // All declarations of 'a' must have identical modifiers.(2687)
}

interface A {
  a?: string;
  // All declarations of 'a' must have identical modifiers.(2687)
  // Subsequent property declarations must have the same type.  Property 'a' must be of type 'string', but here has type 'string | undefined'.(2717)
}

서브셋인 경우2

// ERROR
interface A {
  a: string;
}

interface A {
  a: 'a';
  // Subsequent property declarations must have the same type.  Property 'a' must be of type 'string', but here has type '"a"'.(2717)
}

집합론적으로 타입 바라보기

  • 구조적 타이핑이란? 내용을 다시 한번 읽고 보시면 좋습니다.
  • 타입을 공변의 입장에서 바라봅니다.
    • 할당 가능성을 중심으로 살펴봅니다.
  • 타입을 개념으로 바라보는지 원소로 바라보는지에 따라서도 차이가 있습니다.

number는 숫자의 슈퍼 타입이다.

  • number > 1
// ERROR
const a: number = 1;
const b: 1 = a; // Type 'number' is not assignable to type '1'.(2322)

// OK
const c: 1 = 1;
const d: number = c;

readonly Array는 Array의 슈퍼 타입이다.

  • readonly Array > Array
// ERROR
const a: readonly [1] = [1];
const b: [1] = a; // The type 'readonly [1]' is 'readonly' and cannot be assigned to the mutable type '[1]'.(4104)

// OK
const c: [1] = [1];
const d: readonly [1] = c;

객체를 인자로 넘기는 경우는 어떤가

function a(obj: { b: string }) {
  return obj;
}

// OK
const c = {
  b: '1',
};
a(c);

// OK(임시 변수를 도입하면 잉여 속성 체크를 건너뛸 수 있다)
const d = {
  b: '1',
  e: '1',
};
a(c);

a({ b: '1' });
a({ b: '1', e: '1' }); // Argument of type '{ b: string; e: string; }' is not assignable to parameter of type '{ b: string; }'. Object literal may only specify known properties, and 'e' does not exist in type '{ b: string; }'.(2345)
  • 일반적으로 객체에 속성이 더 있다면 잉여 속성을 체크하지 않아서 문제가 없습니다. 하지만 함수에 매개변수로 전달할 때 잉여 속성 체크가 수행됩니다. 이것은 구조적 타이핑의 한계를 제한하기 위해 도입된 것이므로 구조적 타이핑 입장에서의 집합론에서는 잉여 속성이 있어도 서브 타입으로 보는 것이 맞습니다.

인터페이스로 바라보기

interface A {
  a: string | undefined;
}

// OK
interface B extends A {
  a: string;
}

// OK
interface C extends A {
  a: undefined;
}

interface D {
  a: undefined;
}

// ERROR
interface E extends D {
  a: string;
}

// 특이하게 바라보기
interface F {
  a: string;
  b: string;
}

type G = Omit<F, 'a'>;

let f: F = {
  a: 'a',
  b: 'b',
};

let g: G = {
  b: 'b',
};

// OK
g = f;
// Property 'a' is missing in type 'G' but required in type 'F'.(2741)
f = g;

interface H {
  a: string;
}

interface I {
  b: string;
}

// 요 개념이 아니라 interface J로 바라봐야 함
type J = H & I;

const h: H = {
  a: 'a',
};

const i: I = {
  b: 'b',
};

interface J {
  c: string;
}

interface H extends J {
  a: string;
}

interface I extends J {
  b: string;
}

집합이란?

  • 특정 조건을 만족시키는 대상의 모임입니다.