Javascript

TypeScript 객체 타입 Union과 Intersection

드리프트2 2025. 3. 22. 17:02

TypeScript 객체 타입 Union과 Intersection

안녕하세요!

오늘은 TypeScript (타입스크립트)에서 객체 타입의 Union (유니온)과 Intersection (인터섹션)을 어떻게 활용할 수 있는지 쉽고 재미있게 알아보는 시간을 가져볼까 합니다.

이번 글에서 ‘객체 타입’이라는 용어는 다음과 같은 타입들을 의미합니다.

  • 객체 리터럴 타입 (Object literal type)
  • 인터페이스 타입 (Interface type)
  • Mapped 타입 (Mapped type) (예: Record (레코드))
  1. 객체 타입 Union (유니온)에서 Discriminated Union (디스크리미네이티드 유니온)으로

객체 타입의 Union (유니온)은 하나의 타입이 여러 가지 모습으로 표현될 수 있을 때 유용하게 사용할 수 있습니다.

예를 들어, Shape (모양) 타입이 삼각형 (Triangle), 사각형 (Rectangle), 원 (Circle) 중 하나가 될 수 있다고 가정해볼까요?

type Shape = Triangle | Rectangle | Circle;

type Triangle = {
  point1: Point,
  point2: Point,
  point3: Point,
};
type Rectangle = {
  point1: Point,
  point2: Point,
};
type Circle = {
  center: Point,
  radius: number,
};

type Point = {
  x: number,
  y: number,
};
    1. 예시: 객체 Union (유니온)

다음 타입들은 간단한 가상 파일 시스템을 정의합니다.

type VirtualFileSystem = Map<string, FileEntry>;

type FileEntry = FileEntryData | FileEntryGenerator | FileEntryFile;
type FileEntryData = {
  data: string,
};
type FileEntryGenerator = {
  generator: (path: string) => string,
};
type FileEntryFile = {
  path: string,
};

 

VirtualFileSystem (가상 파일 시스템)을 위한 readFile() 함수는 다음과 같이 작동할 수 있습니다.

(A 라인과 B 라인)

const vfs: VirtualFileSystem = new Map([
  [ '/tmp/file.txt',
    { data: 'Hello!' }
  ],
  [ '/tmp/echo.txt',
    { generator: (path: string) => path }
  ],
]);
assert.equal(
  readFile(vfs, '/tmp/file.txt'), // (A)
  'Hello!'
);
assert.equal(
  readFile(vfs, '/tmp/echo.txt'), // (B)
  '/tmp/echo.txt'
);

 

다음은 readFile() 함수의 구현입니다.

import * as fs from 'node:fs';
function readFile(vfs: VirtualFileSystem, path: string): string {
  const fileEntry = vfs.get(path);
  if (fileEntry === undefined) {
    throw new Error('Unknown path: ' + JSON.stringify(path));
  }
  if ('data' in fileEntry) { // (A)
    return fileEntry.data;
  } else if ('generator' in fileEntry) { // (B)
    return fileEntry.generator(path);
  } else if ('path' in fileEntry) { // (C)
    return fs.readFileSync(fileEntry.path, 'utf-8');
  } else {
    throw new UnexpectedValueError(fileEntry); // (D)
  }
}

 

처음에 fileEntry의 타입은 FileEntry이고, FileEntry는 다음과 같이 정의되어 있습니다.

FileEntryData | FileEntryGenerator | FileEntryFile

 

따라서 fileEntry의 속성에 접근하기 전에, fileEntry의 타입을 이 Union (유니온) 타입의 요소 중 하나로 좁혀야 합니다.

TypeScript (타입스크립트)는 in 연산자를 사용해서 타입 좁히기를 할 수 있도록 도와줍니다.

(A 라인, B 라인, C 라인)

추가적으로 (D) 라인에서는 fileEntrynever 타입에 할당 가능한지 확인해서, 모든 가능한 케이스를 다 처리했는지 정적으로 검사합니다.

never 타입 검사는 다음 클래스를 통해서 구현됩니다.

class UnexpectedValueError extends Error {
  constructor(_value: never) {
    super();
  }
}

 

    1. Discriminated Union (디스크리미네이티드 유니온)으로서의 FileEntry

Discriminated Union (디스크리미네이티드 유니온) 은 객체 타입의 Union (유니온)인데, Union (유니온)의 모든 요소들이 공통된 속성을 하나 가지고 있고, 이 속성 값으로 Union (유니온) 요소의 타입을 구별할 수 있는 Union (유니온)을 말합니다.

FileEntry 타입을 Discriminated Union (디스크리미네이티드 유니온)으로 바꿔볼까요?

type FileEntry =
  | {
    kind: 'FileEntryData',
    data: string,
  }
  | {
    kind: 'FileEntryGenerator',
    generator: (path: string) => string,
  }
  | {
    kind: 'FileEntryFile',
    path: string,
  }
  ;
type VirtualFileSystem = Map<string, FileEntry>;

 

Discriminated Union (디스크리미네이티드 유니온)에서 타입 정보를 담고 있는 속성을 discriminant (디스크리미넌트) 또는 type tag (타입 태그) 라고 부릅니다.

FileEntry의 discriminant (디스크리미넌트)는 .kind 속성입니다.

.tag, .key, .type 같은 이름도 흔히 사용됩니다.

FileEntry 타입 정의가 이전보다 더 길어진 면이 있지만, discriminant (디스크리미넌트) 덕분에 여러 가지 장점이 생겼는데요.

곧 자세히 알아보겠습니다.

    1. Discriminated Union (디스크리미네이티드 유니온)과 Algebraic Data Type (대수적 데이터 타입)의 관계

여담으로, Discriminated Union (디스크리미네이티드 유니온)은 함수형 프로그래밍 언어의 Algebraic Data Type (대수적 데이터 타입)과 관련이 있습니다.

Haskell (하스켈)에서 FileEntry를 Algebraic Data Type (대수적 데이터 타입)으로 표현하면 다음과 같습니다.

(만약 TypeScript (타입스크립트) Union (유니온) 요소들이 더 많은 속성을 가지고 있다면, Haskell (하스켈)에서는 레코드를 사용했을 겁니다.)

data FileEntry = FileEntryData String
  | FileEntryGenerator (String -> String)
  | FileEntryFile String
    1. 새로운 FileEntry를 위한 readFile()

새로운 형태의 FileEntry에 맞춰서 readFile() 함수를 수정해볼까요?

function readFile(vfs: VirtualFileSystem, path: string): string {
  const fileEntry = vfs.get(path);
  if (fileEntry === undefined) {
    throw new Error('Unknown path: ' + JSON.stringify(path));
  }
  switch (fileEntry.kind) {
    case 'FileEntryData':
      return fileEntry.data;
    case 'FileEntryGenerator':
      return fileEntry.generator(path);
    case 'FileEntryFile':
      return fs.readFileSync(fileEntry.path, 'utf-8');
    default:
      throw new UnexpectedValueError(fileEntry);
  }
}

 

Discriminated Union (디스크리미네이티드 유니온)의 첫 번째 장점이 바로 이것인데요.

switch 구문을 사용할 수 있다는 것입니다.

그리고 .kind 속성이 Union (유니온) 타입 요소들을 구별해준다는 것을 한눈에 알 수 있습니다.

이전처럼 Union (유니온) 요소들을 구별해주는 속성 이름을 따로 찾아볼 필요가 없는 거죠.

타입 좁히기는 이전과 똑같이 잘 작동합니다.

.kind 속성으로 타입을 체크하고 나면, 관련된 모든 속성에 자유롭게 접근할 수 있습니다.

    1. Discriminated Union (디스크리미네이티드 유니온)의 장단점

단점: 객체 타입 Union (유니온)을 Discriminated Union (디스크리미네이티드 유니온)으로 만들면 타입 정의가 더 길어집니다.

장점:

  • switch 구문으로 케이스를 처리할 수 있습니다.
  • 어떤 속성이 Union (유니온) 요소들을 구별하는지 명확하게 알 수 있습니다.
    1. 장점: Inline (인라인) Union (유니온) 타입 요소는 설명을 포함합니다.

또 다른 장점은, Union (유니온) 요소가 Inline (인라인)으로 정의된 경우 (이름이 있는 타입으로 외부에서 정의되지 않고 Union (유니온) 타입 정의 내부에 직접 정의된 경우), 각 요소가 어떤 역할을 하는지 코드를 읽는 사람이 더 쉽게 파악할 수 있다는 것입니다.

type Shape =
| {
  tag: 'Triangle',
  point1: Point,
  point2: Point,
  point3: Point,
}
| {
  tag: 'Rectangle',
  point1: Point,
  point2: Point,
}
| {
  tag: 'Circle',
  center: Point,
  radius: number,
}
;
    1. 장점: Union (유니온) 요소들이 고유한 속성을 가질 필요가 없습니다.

Discriminated Union (디스크리미네이티드 유니온)은 Union (유니온) 요소들의 일반적인 속성이 모두 같더라도 잘 작동합니다.

type Temperature =
  | {
    type: 'TemperatureCelsius',
    value: number,
  }
  | {
    type: 'TemperatureFahrenheit',
    value: number,
  }
;
    1. 객체 타입 Union (유니온)의 일반적인 장점: 설명력

다음 타입 정의는 간결하지만, 어떻게 작동하는지 바로 감이 오시나요?

type OutputPathDef =
  | null // 입력 경로와 동일
  | '' // 출력 경로 stem (확장자 제외 경로)
  | string // 다른 확장자를 가진 출력 경로

 

Discriminated Union (디스크리미네이티드 유니온)을 사용하면 코드 자체가 훨씬 더 설명을 잘 해주는 코드가 됩니다.

type OutputPathDef =
  | { key: 'sameAsInputPath' }
  | { key: 'inputPathStem' }
  | { key: 'inputPathStemPlusExt', ext: string }
  ;

 

다음은 OutputPathDef 타입을 사용하는 함수입니다.

import * as path from 'node:path';
function deriveOutputPath(def: OutputPathDef, inputPath: string): string {
  if (def.key === 'sameAsInputPath') {
    return inputPath;
  }
  const parsed = path.parse(inputPath);
  const stem = path.join(parsed.dir, parsed.name);
  switch (def.key) {
    case 'inputPathStem':
      return stem;
    case 'inputPathStemPlusExt':
      return stem + def.ext;
  }
}
const zip = { key: 'inputPathStemPlusExt', ext: '.zip' } as const;
assert.equal(
  deriveOutputPath(zip, '/tmp/my-dir'),
  '/tmp/my-dir.zip'
);
  1. Discriminated Union (디스크리미네이티드 유니온)에서 타입 추출하기

이번 섹션에서는 Discriminated Union (디스크리미네이티드 유니온)에서 타입을 추출하는 방법을 알아볼 건데요.

예시로 다음 Discriminated Union (디스크리미네이티드 유니온) 타입을 사용하겠습니다.

type Content =
  | {
    kind: 'text',
    charCount: number,
  }
  | {
    kind: 'image',
    width: number,
    height: number,
  }
  | {
    kind: 'video',
    width: number,
    height: number,
    runningTimeInSeconds: number,
  }
;
    1. Discriminant (디스크리미넌트) 값 (타입 태그) 추출하기

Discriminant (디스크리미넌트) 값을 추출하기 위해 조건부 타입과 infer 키워드를 사용할 수 있습니다.

type GetKind<T extends {kind: string}> =
  T extends {kind: infer S} ? S : never;

type ContentKind = GetKind<Content>;

type _ = Assert<Equal<
  ContentKind,
  'text' | 'image' | 'video'
>>;

 

조건부 타입은 Union (유니온) 타입에 분배 법칙을 적용하기 때문에, GetKind 타입은 Content Union (유니온)의 각 요소에 적용됩니다.

infer 키워드는 .kind 속성의 값 타입을 추출하는 역할을 합니다.

    1. Discriminated Union (디스크리미네이티드 유니온) 요소들을 위한 Map (맵)

이전 섹션에서 만든 ContentKind 타입을 사용하면, Content 요소들을 위한 완전한 Map (맵)을 정의할 수 있습니다.

const DESCRIPTIONS_FULL: Record<ContentKind, string> = {
  text: 'plain text',
  image: 'an image',
  video: 'a video',
} as const;

 

만약 Map (맵)이 완전하지 않아도 된다면, 유틸리티 타입 Partial을 사용할 수 있습니다.

const DESCRIPTIONS_PARTIAL: Partial<Record<ContentKind, string>> = {
  text: 'plain text',
} as const;
    1. Discriminated Union (디스크리미네이티드 유니온)의 서브타입 추출하기

때로는 Discriminated Union (디스크리미네이티드 유니온) 전체가 필요하지 않고 일부 서브타입만 필요할 때가 있습니다.

Content의 서브타입을 추출하기 위한 유틸리티 타입을 직접 만들어볼까요?

type ExtractSubtype<
  Union extends {kind: string},
  SubKinds extends GetKind<Union> // (A)
> =
  Union extends {kind: SubKinds} ? Union : never // (B)
;

 

조건부 타입을 사용해서 Union (유니온) 타입 U를 순회합니다.

  • (B) 라인: Union (유니온) 요소의 .kind 속성 타입이 SubKinds에 할당 가능하다면 해당 요소를 유지하고, 그렇지 않다면 never를 반환해서 요소를 생략합니다.
  • (A) 라인: extends 제약 조건을 사용해서 타입 추출 시 오타를 방지합니다. Discriminant (디스크리미넌트) 값인 SubKinds는 반드시 GetKind<Union>의 서브셋이어야 합니다. (이전 섹션 참고)

ExtractSubtype을 사용하는 예시입니다.

type _ = Assert<Equal<
  ExtractSubtype<Content, 'text' | 'image'>,
  | {
    kind: 'text',
    charCount: number,
  }
  | {
    kind: 'image',
    width: number,
    height: number,
  }
>>;

 

ExtractSubtype 대신, 내장 유틸리티 타입인 Extract를 사용할 수도 있습니다.

type _ = Assert<Equal<
  Extract<Content, {kind: 'text' | 'image'}>,
  | {
    kind: 'text',
    charCount: number,
  }
  | {
    kind: 'image',
    width: number,
    height: number,
  }
>>;

 

ExtractContent Union (유니온)에서 다음 타입에 할당 가능한 모든 요소를 반환합니다.

{kind: 'text' | 'image'}
  1. 클래스 계층 vs. Discriminated Union (디스크리미네이티드 유니온)

클래스 계층과 Discriminated Union (디스크리미네이티드 유니온)을 비교하기 위해, 둘 다 사용해서 다음과 같은 표현식의 syntax tree (구문 분석 트리)를 정의해볼 건데요.

1 + 2 + 3

syntax tree (구문 분석 트리)는 다음 중 하나입니다.

  • 숫자 값
  • 두 개의 syntax tree (구문 분석 트리)의 덧셈
    1. syntax tree (구문 분석 트리)를 위한 클래스 계층

다음 코드는 추상 클래스와 두 개의 서브클래스를 사용해서 syntax tree (구문 분석 트리)를 표현합니다.

abstract class SyntaxTree {
  abstract evaluate(): number;
}

class NumberValue extends SyntaxTree {
  numberValue: number;
  constructor(numberValue: number) {
    super();
    this.numberValue = numberValue;
  }
  evaluate(): number {
    return this.numberValue;
  }
}
class Addition extends SyntaxTree {
  operand1: SyntaxTree;
  operand2: SyntaxTree;
  constructor(operand1: SyntaxTree, operand2: SyntaxTree) {
    super();
    this.operand1 = operand1;
    this.operand2 = operand2;
  }
  evaluate(): number {
    return this.operand1.evaluate() + this.operand2.evaluate();
  }
}

 

evaluate 연산은 “숫자 값”과 “덧셈” 두 가지 케이스를 각 클래스에서 polymorphism (다형성)을 통해 처리합니다.

사용 예시는 다음과 같습니다.

const syntaxTree = new Addition(
  new NumberValue(1),
  new Addition(
    new NumberValue(2),
    new NumberValue(3),
  ),
);
assert.equal(
  syntaxTree.evaluate(), 6
);
    1. syntax tree (구문 분석 트리)를 위한 Discriminated Union (디스크리미네이티드 유니온)

다음 코드는 두 개의 요소로 이루어진 Discriminated Union (디스크리미네이티드 유니온)을 사용해서 syntax tree (구문 분석 트리)를 표현합니다.

type SyntaxTree =
  | {
    kind: 'NumberValue';
    numberValue: number;
  }
  | {
    kind: 'Addition';
    operand1: SyntaxTree;
    operand2: SyntaxTree;
  }
;

function evaluate(syntaxTree: SyntaxTree): number {
  switch(syntaxTree.kind) {
    case 'NumberValue':
      return syntaxTree.numberValue;
    case 'Addition':
      return (
        evaluate(syntaxTree.operand1) +
        evaluate(syntaxTree.operand2)
      );
    default:
      throw new UnexpectedValueError(syntaxTree);
  }
}

 

evaluate 연산은 “숫자 값”과 “덧셈” 두 가지 케이스를 switch 구문을 통해 한 곳에서 처리합니다.

사용 예시는 다음과 같습니다.

const syntaxTree: SyntaxTree = { // (A)
  kind: 'Addition',
  operand1: {
    kind: 'NumberValue',
    numberValue: 1,
  },
  operand2: {
    kind: 'Addition',
    operand1: {
      kind: 'NumberValue',
      numberValue: 2,
    },
    operand2: {
      kind: 'NumberValue',
      numberValue: 3,
    },
  }
};
assert.equal(
  evaluate(syntaxTree), 6
);

 

(A) 라인에서 타입 주석은 필수는 아니지만, 데이터가 올바른 구조를 가지고 있는지 확인하는 데 도움을 줍니다.

타입 주석을 생략해도 나중에 문제를 발견할 수 있습니다.

    1. 클래스와 Discriminated Union (디스크리미네이티드 유니온) 비교

클래스를 사용할 때는 instanceof 연산자로 인스턴스의 타입을 체크합니다.

Discriminated Union (디스크리미네이티드 유니온)을 사용할 때는 discriminant (디스크리미넌트)를 사용해서 타입을 체크합니다.

어떤 면에서는 discriminant (디스크리미넌트)가 런타임 타입 정보를 제공하는 셈입니다.

각 접근 방식은 한 종류의 확장성에 강점을 가집니다.

  • 클래스의 경우, 새로운 연산을 추가하려면 각 클래스를 수정해야 합니다. 하지만 새로운 타입을 추가하는 것은 기존 코드 변경 없이 가능합니다.
  • Discriminated Union (디스크리미네이티드 유니온)의 경우, 새로운 타입을 추가하려면 각 함수를 수정해야 합니다. 반면에 새로운 연산을 추가하는 것은 간단합니다.
  1. 객체 타입 Intersection (인터섹션)

두 객체 타입의 Intersection (인터섹션)은 두 타입의 속성을 모두 가집니다.

type Obj1 = { prop1: boolean };
type Obj2 = { prop2: number };

const obj: Obj1 & Obj2 = {
  prop1: true,
  prop2: 123,
};
    1. Extends (확장) vs. Intersection (인터섹션)

인터페이스를 사용할 때는 extends 키워드를 사용해서 속성을 추가할 수 있습니다.

interface Person {
  name: string;
}
interface Employee extends Person {
  company: string;
}

 

객체 타입을 사용할 때는 Intersection (인터섹션)을 사용할 수 있습니다.

type Person = {
  name: string,
};

type Employee =
  & Person
  & {
    company: string,
  }
;

 

주의할 점은 extends만 overriding (오버라이딩)을 지원한다는 것입니다.

    1. 예시: 추론된 Intersection (인터섹션)

다음 코드는 in 타입 가드 (type guard)를 사용했을 때 (A 라인과 B 라인) obj의 추론된 타입이 어떻게 변하는지 보여줍니다.

function func(obj: object) {
  if ('prop1' in obj) { // (A)
    assertType<
      object & Record<'prop1', unknown>
    >(obj);
    if ('prop2' in obj) { // (B)
      assertType<
      object & Record<'prop1', unknown> & Record<'prop2', unknown>
      >(obj);
    }
  }
}
    1. 예시: Intersection (인터섹션)을 통해 두 객체 타입 결합하기

다음 예시에서는 함수 매개변수 obj의 타입 ObjWithKey 타입과 Intersection (인터섹션)해서, WithKey 타입의 .key 속성을 매개변수에 추가합니다.

type WithKey = {
  key: string,
};
function addKey<Obj extends object>(obj: Obj, key: string)
  : Obj & WithKey
{
  const objWithKey = obj as (Obj & WithKey);
  objWithKey.key = key;
  return objWithKey;
}

 

addKey() 함수는 다음과 같이 사용할 수 있습니다.

const paris = {
  city: 'Paris',
};

const parisWithKey = addKey(paris, 'paris');
assertType<
{
  city: string,
  key: string,
}
>(parisWithKey);

 

TypeScript (타입스크립트) 객체 타입 Union (유니온)과 Intersection (인터섹션)에 대한 설명은 여기까지입니다.