kokoball의 devlog
article thumbnail
728x90

이번 글은 TypeScript 문법 중 유니온 타입과 인터섹션 타입, 타입 가드와 타입 단언에 대한 내용입니다.

분명히 알고 있으면서 사용해 봤던 문법들이지만, 설명하려고 하면 애를 먹는 만큼 이번 기회에 한번 더 정리하려고 합니다.

 

유니온 타입이란?

유니온 타입(Union Type)이란 자바스크립트의 OR 연산자(||)와 같이 'A' 이거나 'B'이다라는 의미의 타입입니다.

function logText(text: string | number) {
  // ...
}

 

함수의 파라미터 text에는 문자열 타입이나 숫자 타입이 모두 있습니다.
이처럼 연산자 이용하여 타입을 여러 연결하는 방식을 유니온 타입 정의 방식이라고 부릅니다.

 

유니온 타입의 장점

유니온 타입의 장점은 아래 예시를 확인해 보면 바로 있습니다.

// any 예시
function getAge(age: any) {
 age.toFixed(); // 에러 발생
 return age;
}

// 유니온 타입 예시
function getAge(age: number | string) {
 if(typeof age == ‘number’) {
  return age.toFixed(); // 정상 동작
 }
  
 if(typeof age == 'string') {
   return age;
 }
  
 return new TypeError('age must be number of string');
}

 

첫 번째 any를 사용하는 경우는 파라미터의 정확한 타입을 알 수 없기 때문에 에러가 발생합니다.

반면에, 유니온 타입을 사용하면 파라미터의 타입이 정확히 number로 추론되기 때문에 자동 완성 및 에러를 방지할 수 있습니다.

 

Union Type을 쓸 때 주의할 점

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

interface Developer {
  name: string;
  skill: string;
}

function introduce(someone: Person | Developer) {
  someone.name; // 정상 동작
  someone.age; // 타입 오류
  someone.skill; // 타입 오류
}

 

타입스크립트 관점에서는 introduce 함수를 호출하는 시점에 Person 타입이 올지 Developer 타입이 올지 알 수가 없습니다.

그렇기 때문에 어느 타입이 들어오든 오류가 안 나는 방향으로 타입을 추론하게 되며 두 타입에 공통적으로 들어있는 속성인 name만 접근할 수 있습니다.

 

Person과 Developer 속성을 introduce 함수 안에서 접근하고 싶은 경우 별도의 타입 가드를 사용하여 타입의 범위를 좁혀야 합니다.

 

인터섹션 타입

인터섹션 타입(Intersection Type)은 여러 타입을 모두 만족하는 하나의 타입을 의미합니다.

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

interface Developer {
    name: string;
    skill: number;
}

type Capt = Person & Developer;

 

Person 인터페이스와 Developer 인터페이스를 & 연산자를 이용하여 합친 후 Capt이라는 타입에 할당했으며, 결과적으로 Capt의 타입은 아래와 같이 정의됩니다.

{
  name: string;
  age: number;
  skill: string;
}

 

이처럼 연산자 이용해 여러 개의 타입 정의를 하나로 합치는 방식을 인터섹션 타입 정의 방식이라고 합니다.

 

타입 가드(Type Guard)

타입 가드는 변수나 객체의 타입을 런타임에 안전하게 확인하는 방법이며, 이를 활용해서 TypeScript에서는 실제 타입을 더 정확하게 좁힐 수 있습니다

 

장점: 런타임에서 타입의 안정성이 높아지며, 잘못된 타입 사용으로 인한 오류를 줄일 수 있습니다.

단점: 코드가 조금 더 길어질 수 있으며, 모든 가능한 프로퍼티나 메서드를 명시적으로 검사해야 할 수도 있습니다.

 

TypeScript는 JavaScript의 instanceof, typeof 연산자를 이해할 수 있습니다. 즉 조건문에 typeofinstanceof를 사용하면, TypeScript는 해당 조건문 블록 내에서는 해당 변수의 타입이 다르다는 것(=좁혀진 범위의 타입)을 이해합니다.

 

typeof

function doSomething(x: number | string) {
  if (typeof x === 'string') { // TypeScript는 이 조건문 블록 안에 있는 `x`는 백퍼 `string`이란 걸 알고 있습니다.
  console.log(x.subtr(1)); // Error: `subtr`은 `string`에 존재하지 않는 메소드입니다.
  console.log(x.substr(1)); // ㅇㅋ
  }
  x.substr(1); // Error: `x`가 `string`이라는 보장이 없죠.
}

 

instanceof

class Foo {
  foo = 123;
  common = '123';
}

class Bar {
  bar = 123;
  common = '123';
}

function doStuff(arg: Foo | Bar) {
  if (arg instanceof Foo) {
    console.log(arg.foo); // ㅇㅋ
    console.log(arg.bar); // Error!
  }

  if (arg instanceof Bar) {
    console.log(arg.foo); // Error!
    console.log(arg.bar); // ㅇㅋ
  }

  console.log(arg.common); // ㅇㅋ
  console.log(arg.foo); // Error!
  console.log(arg.bar); // Error!
}

doStuff(new Foo());
doStuff(new Bar());

 

또한 if문으로 타입을 좁혀내면, else문 안의 변수 타입은 절대 동일한 타입이 수는 없음을 인지합니다.

class Foo {
  foo = 123;
}

class Bar {
  bar = 123;
}

function doStuff(arg: Foo | Bar) {
  if (arg instanceof Foo) {
    console.log(arg.foo); // ㅇㅋ
    console.log(arg.bar); // Error!
  }
  else {  // 백퍼 Bar겠군.
    console.log(arg.foo); // Error!
    console.log(arg.bar); // ㅇㅋ
  }
}

doStuff(new Foo());
doStuff(new Bar());

 

in

in은 객체 내부에 특정 property가 존재하는지를 확인하는 연산자로 type guard로 활용할 수 있습니다.

interface A {
  x: number;
}

interface B {
  y: string;
}

function doStuff(q: A | B) {
  if ('x' in q) {
  // q: A
  }
  else {
  // q: B
  }
}

 

리터럴 Type Guard

리터럴 값의 경우 === / == /!== /!= 연산자를 사용해 타입을 구분할 수 있습니다.

type TriState = 'yes' | 'no' | 'unknown';

function logOutState(state:TriState) {
  if (state == 'yes') {
  console.log('사용자가 yes를 골랐습니다');
  } else if (state == 'no') {
  console.log('사용자가 no를 골랐습니다');
  } else {
  console.log('사용자가 아직 결정을 내리지 않았습니다.');
  }
}

 

Union 타입

이는 union 타입에 리터럴 타입이 있는 경우에도 동일하게 적용됩니다.

union 타입의 공통 property 값을 비교해 union 타입을 구분할 수 있습니다.

type Foo = {
  kind: 'foo', // 리터럴 타입
  foo: number
}

type Bar = {
  kind: 'bar', // 리터럴 타입
  bar: number
}

function doStuff(arg: Foo | Bar) {
  if (arg.kind === 'foo') {
  console.log(arg.foo); // ㅇㅋ
  console.log(arg.bar); // Error!
  }
  else {  // 백퍼 Bar겠군.
  console.log(arg.foo); // Error!
  console.log(arg.bar); // ㅇㅋ
  }
}

 

null과 undefined (strictNullChecks)

TypeScript는 a == null / != null로 null과 undefined 모두 걸러낼 수 있습니다.

function foo(a?: number | null) {
  if (a == null) return;
  // 이제부터 a는 무조건 number입니다.
}

 

사용자 정의 Type Guards

JavaScript 언어는 풍부한 런타임 내부 검사(=runtime introspection support)를 지원하진 않습니다. 일반 JavaScript 객체(구조적 타입 structural typings 활용)를 사용할 때에는 instanceof나 typeof와 같은 연산자를 액세스 조차 할 수 없습니다. 하지만 TypeScript에서는 사용자 정의 Type Guard 함수를 만들어 이를 해결할 수 있습니다.

 

사용자 정의 Type Guard 함수란 단순히 어떤 인자명은 어떠한 타입이다라는 값을 리턴하는 함수일 뿐입니다. 

 

/**
 * 일반적인 인터페이스 예
 */

interface Foo {
  foo: number;
  common: string;
}

interface Bar {
  bar: number;
  common: string;
}

/**
 * 사용자 정의 Type Guard!
 */

function isFoo(arg: any): arg is Foo {
  return arg.foo !== undefined;
}

/**
 * 사용자 정의 Type Guard 사용 예시
 */

function doStuff(arg: Foo | Bar) {
  if (isFoo(arg)) {
    console.log(arg.foo); // ㅇㅋ
    console.log(arg.bar); // Error!
  }
  else {
    console.log(arg.foo); // Error!
    console.log(arg.bar); // ㅇㅋ
  }
}

doStuff({ foo: 123, common: '123' });
doStuff({ bar: 123, common: '123' });

 

Type Guard와 Callback

TypeScript는 콜백 함수 내에서 type guard가 계속 유효하다고 여기지 않습니다. 이는 매우 위험하기 때문입니다.

// Example Setup
declare var foo:{bar?: {baz: string}};
function immediate(callback: () => void) {
  callback();
}

// Type Guard
if (foo.bar) {
  console.log(foo.bar.baz); // ㅇㅋ
  functionDoingSomeStuff(() => {
    console.log(foo.bar.baz); // TS error: 해당 객체는 'undefined'일 가능성이 있습니다.
  });
}

 

해결법은 아주 간단합니다. 로컬 변수를 선언하고 그 안에 값을 담아 타입 추론이 가능하도록 만들 수 있습니다.

이는 해당 변수의 타입이 외부 요인으로 인해 바뀔 가능성이 없다는 걸 자동으로 보장하고, TypeScript 또한 이를 쉽게 이해할 수 있습니다.

// Type Guard
if (foo.bar) {
  console.log(foo.bar.baz); // ㅇㅋ
  const bar = foo.bar;
  functionDoingSomeStuff(() => {
  console.log(bar.baz); // ㅇㅋ
  });
}

타입 단언(Type Assertion)

타입단언은 TypeScript에서 개발자가 특정 변수나 객체가 특정 타입이라고 "주장"하는 방법이며, 컴파일러에게 "나는 이 변수의 타입을 알고 있으며, 이것은 안전하다"라는 것을 알려주는 방식입니다.

 

장점: 코드가 간결하며, TypeScript의 타입 추론을 우회할 수 있습니다.

단점: 잘못된 주장을 할 경우, 런타임 에러가 발생할 수 있으며, 개발자의 주장이 항상 정확하다는 보장이 없습니다.

const content = value
    ? (item as IGroup).group
    : (item as ITab).content;

 

이 예제는 삼항 연산자를 사용하고 있습니다.

value 값이 true일 경우 (item as IGroup).group를 실행하고, false일 경우 (item as ITab).content를 실행하며 as 키워드는 Type Assertion을 나타냅니다.

 

여기서는 item을 IGroup 또는 ITab 중 하나로 간주함으로써 TypeScript 컴파일러에게 해당 변수의 타입이 확정적이라고 알려주는 역할을 하게 됩니다.

 

타입 단언은 개발자가 타입을 확실히 알고 있다는 것을 의미하므로 컴파일러가 타입 검사를 스킵하게 되며, 런타임에서 item이 실제로 해당 타입이 아니라면 에러가 발생하게 됩니다.

 

어느 것을 사용하는 것이 더 좋을까?

Type Assertion(타입 단언)은 타입을 확실히 알고 있으며,  런타임에서의 오류를 발생시킬 가능성이 있으므로 해당 주장이 100% 안전할 때 사용해야 합니다. 간단히 말해, 개발자가 컴퓨터만큼이나 정확하다고 판단될 때 적용하는 방법입니다.

 

코드의 안정성을 최우선으로 생각한다면 Type Guard(타입 가드)를 사용하는 것이 바람직합니다. 특히, 외부에서 들어오는 데이터나 라이브러리와 같이 타입이 확실하지 않은 경우에 좋습니다.

 

결론적으로, 안정성과 간결성 사이에서의 균형을 선택해야 한며, 가능한 타입 가드를 사용하여 코드의 안정성을 높이는 것이 좋습니다.

 

 

 

 

728x90
profile

kokoball의 devlog

@kokoball-dev

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!