타입스크립트 - 서로소 유니온 타입

드엔트론프·2023년 7월 31일
0

typescript

목록 보기
5/12
post-thumbnail

들어가며

  • 지난 글에 타입은 집합이라는 것을 보았으며, 타입 단언 및 타입 좁히기에 대해 간단한 예시와 함께 설명했었다. 이번 글은 지난 글에 있었던 타입 좁히기를 더 직관적으로 설명해주는 서로소 유니온 타입에 대해 설명하려한다.

서로소 유니온 타입

교집합이 없는 타입들로만 만든 유니온 타입
타입 좁히기 할 때 더 직관적으로 쉽고 정확하게 객체 타입을 정의하는 특별한 방법

  • 권한별 각자의 기능이 있다고 생각해보자. admin은 강퇴, member는 포인트, guest는 방문 횟수가 있다고 말이다.
type Admin = {
  name: string;
  kickCount: number;
};
type Member = {
  name: string;
  point: number;
};
type Guest = {
  name: string;
  visitCount: number;
};

type User = Admin | Member | Guest;

//Admin -> {name} 님 현재까지 {kickCount}명 강퇴했습니다.
//Member -> {name}님 현재까지 {point} 모았습니다.
//Guest -> {name}님 현재까지 {visitCount} 번 오셨습니다.
function logic(user: User) {
  if ("kickCount" in user) {
    console.log(`${user.name}님 현재까지 ${user.kickCount}명 강퇴했습니다.`);
  } else if ("point" in user) {
    console.log(`${user.name}님 현재까지 ${user.point} 모았습니다.`);
  } else {
    console.log(`${user.name}님 현재까지 ${user.visitCount}번 오셨습니다.`);
  }
}
  • 각자의 권한대로 객체안에 프로퍼티들에 타입을 지정해주고, logic 함수안에 in 타입가드로 각 권한이 갖고 있는 프로퍼티일때로 좁혀 console의 내용을 다르게 했다. 어떤가? 누군가 처음 딱 본다면 logic을 바로 이해할 수 있을까? 아니다. kickcount가 뭔데 왜 이렇게 했지 ? 하고 위에 타입 정의를 다시 훑고, point가 뭐지?하며 다시 또 훑는 , 직관적이지 않은 코드인 것이다. 그러면 어떻게 직관적으로 바꿀 수 있을까?

    바로 각 타입마다 tag를 달아주는 것이다.

  • 이 tag를 달아줌으로써, 타입스크립트는 기존과 달리 작동하게 된다. 그 이유는, 문자열 리터럴 타입으로 지정되어 Admin이면서 Member임을 만족하는 값이 없어지는 것이다.
  • 기존에 tag가 없을때는 아래의 그림과 같은 집합관계다.
    • 예를들어 Admin과 member의 부분집합은 {name, kickcount, point} 이다.
      태그 없을 때 집합관계
  • 그런데 tag가 있게되면 아래처럼 바뀐다.
    • 태그의 문자열 리터럴타입은 말그대로 한 개만 지정하기 때문에, 겹칠수가 없는것! 그렇기에 admin과 member, member와 guest, admin과 guest는 겹칠 수가 없다. 이렇게 서로소 유니온 타입이 된 것이다.

      태그 있을 때 관계

/**
 * 서로소 유니온 타입
 * 교집합이 없는 타입들로만 만든 유니온 타입
 */

type Admin = {
  tag: 'ADMIN'
  name: string;
  kickCount: number;
};
type Member = {
  tag: 'MEMBER'
  name: string;
  point: number;
};
type Guest = {
  tag: "GUEST"
  name: string;
  visitCount: number;
};

type User = Admin | Member | Guest;

//Admin -> {name} 님 현재까지 {kickCount}명 강퇴했습니다.
//Member -> {name}님 현재까지 {point} 모았습니다.
//Guest -> {name}님 현재까지 {visitCount} 번 오셨습니다.
function logic(user: User) {
  if (user.tag === 'ADMIN') {
    console.log(`${user.name}님 현재까지 ${user.kickCount}명 강퇴했습니다.`);
  } else if (user.tag === 'MEMBER') {
    console.log(`${user.name}님 현재까지 ${user.point} 모았습니다.`);
  } else {
    console.log(`${user.name}님 현재까지 ${user.visitCount}번 오셨습니다.`);
  }
}
  • 태그를 달아준 것 만으로도 훨씬 보기 좋은 함수로 바뀌었다 !
  • 여기서 한번 더 직관적으로 바꾸려면? switch 문을 써주면 된다.
function logic(user: User) {
  switch (user.tag) {
    case "ADMIN": {
      console.log(`${user.name}님 현재까지 ${user.kickCount}명 강퇴했습니다.`);
      break;
    }
    case "MEMBER": {
      console.log(`${user.name}님 현재까지 ${user.point} 모았습니다.`);
      break;
    }
    case "GUEST": {
      console.log(`${user.name}님 현재까지 ${user.visitCount}번 오셨습니다.`);
      break;
    }
  }
}
  • 눈에 띌 정도로 직관적..🥹

  • 하나 더 예시를 살펴보자.
  • 비동기 작업의 결과를 처리하는 객체의 예시이다.
  • 로딩, 실패, 성공의 상태로 나누어 처리하는 로직이다.
type AsyncTask = {
  state: "LOADING" | "FAILED" | "SUCCESS";
  error?: {
    message: string;
  };
  response?: {
    data: string;
  };
};
  • 에러메세지는 실패했을때만, 응답은 성공했을때만 들어오는 값들이라 ?를 넣어주어 있어도 되고 없어도 되고를 표현했다.
function processResult(task: AsyncTask) {
  switch (task.state) {
    case "LOADING": {
      console.log("로딩중");
      break;
    }
    case "FAILED": {
      console.log(`에러발생: ${task.error?.message}`);
      break;
    }
    case "SUCCESS": {
      console.log(`성공: ${task.response?.data}`);
      break;
    }
  }
}
  • 눈여겨봐야 할곳은 여기다. task.error?.message task.response?.data
  • 어째서 난 타입을 좁혔다 생각했는데 ?인 옵셔널체이닝이 붙을까?
  • error나 response가 있을수도 있고 없을수도 있기 때문이다. 그런데 난 좀 더 명확하게 타입을 지정하고 싶었는데, 물음표라니. 물음표를 지우면 당연하게도 에러가 발생한다.
case "FAILED": {
      console.log(`에러발생: ${task.error.message}`);
  //'task.error'은(는) 'undefined'일 수 있습니다.ts(18048)

      break;
    }
    case "SUCCESS": {
      console.log(`성공: ${task.response.data}`); 
      //'task.response'은(는) 'undefined'일 수 있습니다.ts(18048)

      break;
    }
  • 이를 해결하기 위해서, 각 상태마다의 type을 따로 정의하고, AsyncTask 타입을 서로소타입으로 바꾸는 것이다!
type LoadingTask = {
  state: "LOADING";
};
type FailedTask = {
  state: "FAILED";
  error: {
    message: string;
  };
};
type SuccessTask = {
  state: "SUCCESS";
  response: {
    data: string;
  };
};

type AsyncTask = LoadingTask | FailedTask | SuccessTask;

function processResult(task: AsyncTask) {
  switch (task.state) {
    case "LOADING": {
      console.log("로딩중");
      break;
    }
    case "FAILED": {
      console.log(`에러발생: ${task.error.message}`);
      break;
    }
    case "SUCCESS": {
      console.log(`성공: ${task.response.data}`);
      break;
    }
  }
}
  • switch 문의 각 case 별로, state가 명확하게 LOADING, FAILED, SUCCESS 로 나누어져 있으니 서로 겹칠 수가 없다. FAILED라면 무조건 FailedTask인것. 그래서 타입이 잘 좁혀지게 된다.
  • 이제 ?가 없어도 에러가 발생하지 않고, 명확하게 타입이 좁혀진 것을 알 수 있다!

마치며

  • 서로소 유니온 타입은 타입의 명확한 구분과 더불어 훨씬 가독성 좋게 바꿔준다. 항상 협업하는 입장에서, 이러한 타입 좁히기를 사용해보려 노력해야겠다.
profile
왜? 를 깊게 고민하고 해결하는 사람이 되고 싶은 개발자

0개의 댓글