typescript-exercises : 타입 확장과 좁히기

duo2208·2024년 2월 20일

Language

목록 보기
3/3
post-thumbnail

typescript-exercises 문제를 풀면서 타입 가드를 익힌다.

  1. 자바스크립트 연산자를 활용한 타입 가드 활용
    • 원시 타입을 추론할 때 : typeof 연산자 활용
    • 인스턴스화된 객체 타입을 판별할 때 : instanceof 연산자 활용
    • 객체의 속성이 있는지 없는지에 따른 구분 : in 연산자 활용
  1. 사용자 정의 타입가드 활용
    • is 연산자로 사용자 정의 타입 가드 만들어 활용

exercises3 : in 연산자


[ Analyze ]

유니온 타입으로 선언된 값은 유니온 타입에 포함된 모든 타입이 공통으로 갖고 있는 속성에만 접근할 수 있다.

[ Solution ]

" in 연산자를 활용하여 속성이 있는지 없는지에 따라 조건 분기 "

  • A in B 의 형태.
  • 프로토타입 체인으로 접근할 수 있는 속성이면 전부 true 반환.
interface User {
    name: string;
    age: number;
    occupation: string;
}

interface Admin {
    name: string;
    age: number;
    role: string;
}

export type Person = User | Admin;

...

export function logPerson(person: Person) {
    let additionalInformation: string;
    if ('role' in person) {
        additionalInformation = person.role;
    } else {
        additionalInformation = person.occupation;
    }
    console.log(` - ${person.name}, ${person.age}, ${additionalInformation}`);
}

exercises4 : type predicate


[ Analyze ]

interface User {
    type: 'user';
    name: string;
    age: number;
    occupation: string;
}

interface Admin {
    type: 'admin';
    name: string;
    age: number;
    role: string;
}

export type Person = User | Admin;

export const persons: Person[] = [
	...
];

export function isAdmin(person: Person) {
    return person.type === 'admin';
}

  • 유니온 타입 (union type)

유니온 타입으로 선언된 값은 유니온 타입에 포함된 모든 타입이 공통으로 갖고 있는 속성에만 접근할 수 있다.

User 인터페이스에는 role 속성이 없으며, Admin 인터페이스에는 occupation 속성이 없다. 따라서 인자로 받는 person의 타입이 User 이라면 role 속성을 찾을 수 없다는 아래와 같은 에러가 발생한다.

error TS2339: Property 'role' does not exist on type 'Person'. Property 'role' does not exist on type 'User'.

다시 한 번 강조하자면, person 이라는 유니온 타입은 User 또는 Admin 타입에 해당할 뿐이지 User 이면서 Admin 인 것은 아니다. ( ※ 합집합의 개념)


  • 식별 가능한 유니온 (Discriminated Union)

UserAdmin 인터페이스에 type 이라는 속성이 추가 되었다. 이런 타입을 식별 가능한 유니온이라고 한다. 식별 가능한 유니온을 사용하면 타입 간의 구조 호환을 막고 타입마다 구분할 수 있는 판별자를 달아주어 포함 관계를 제거 할 수 있다.


[ Solution ]

" is 연산자로 사용자 정의 타입 가드를 만들어 활용 "

직접 타입 가드 함수를 만들어 사용한다. 이러한 방식의 타입 가드를 type-predicate 함수 라고 한다.

  • [parameter name] is Type 의 형태.
  • 특정 조건이 참인지 거짓인지 불리언 값을 반환하면서 반환 타입을 지정하는 함수.
  • 함수가 사용되는 곳의 타입을 추론할 때 해당 조건을 타입 가드로 사용하도록 알려준다.
...

export function isAdmin(person: Person): person is Admin {
    return person.type === 'admin';
}

...

📌 TypeScript의 유형 술어: 사용자 정의 유형 가드를 작성하는 방법
📌 Type Narrowing-2(Type Predicate)


exercises5 : utility types


[ Analyze ]

...

export function filterUsers(persons: Person[], criteria: User): User[] {
    return persons.filter(isUser).filter((user) => {
        const criteriaKeys = Object.keys(criteria) as (keyof User)[];
        return criteriaKeys.every((fieldName) => {
            return user[fieldName] === criteria[fieldName];
        });
    });
}

...

filterUsers(
    persons,
    {
        age: 23
    }
).forEach(logPerson);
error TS2345: Argument of type '{ age: number; }' is not assignable to parameter of type 'User'.
Type '{ age: number; }' is missing the following properties from type 'User': type, name, occupation

filterUsers() 함수를 호출할 때 criteria 객체에 User 인터페이스의 모든 속성이 필요한데, 실제로는 age 속성만이 전달되었다.

[ Solution ]

" 유틸리티 타입을 사용하여 중복 타입 선언을 피하고 선택적으로 속성을 사용한다. "

export interface Criteria extends Partial<User> {}

...

export function filterUsers(persons: Person[], criteria: Partial<Omit<User, 'type'>>): User[] {
    return persons.filter(isUser).filter((user) => {
        const criteriaKeys = Object.keys(criteria) as (keyof Omit<User, 'type'>)[];
	...
}

여기선 Partial, Omit 유틸리티 함수를 활용해 type 속성을 제외한 User 타입의 모든 속성을 포함한다.


📌 유틸리티 타입


exercises6 : function overloading


[ Analyze ]

  • fiterPerson()
Fix typing for the filterPersons so that it can filter users
    and return User[] when personType='user' 
    and return Admin[] when personType='admin'. 

filterPerson() 함수의 타입 추론에 먼저 Generics, Function Overloading, Union Type 3가지 방법을 떠올렸다.

1. Function Overloading

  • Aware of all possible arg types? ⭕
  • Retrn type chanes depending on arg types? ⭕
  • More than 1 level of generic extends to get the return type? ❌

상기의 조건을 따지고 함수 오버로딩 방법을 선택. 모범 답안도 함수 오버로딩 방법을 쓰고 있었다.


2. Conditional Type

📌 TypeScript Tip: Using Conditional Types To Refactor Overloads

함수 오버로딩으로 구현을 끝낸 후 위 글을 발견했다. Conditional Type 으로 리팩토링한 이유가 뭘까..?

타입스크립의 함수 오버로딩컴파일 시점에서 타입을 체크하여 오버로딩된 시그니처 중에서 가장 일치하는 시그니처를 선택한다. 하지만 선택된 시그니처와 실제로 호출된 함수의 구현이 일치하지 않으면 컴파일러가 경고하지 않는다. 이는 JavaScript의 동적 타입 특성 때문에 발생하는 문제이다.

따라서 타입스크립트에서는 함수 오버로딩이 완전한 타입 안전성을 보장하지 않는다고 한다.

📌 TypeScript: Can I achieve this use generics and conditional types instead of function overloads?

As I know all what you can do with overloads you can do with conditionals but not all what you can do with conditionals can be done with overloads. But conditionals require more code, effort so I would use them for complex things. In simple cases like this, better to use overloads.

함수 오버로딩으로 구현 가능한 모든 것은 Conditional Type 으로도 구현 가능하므로, 조건에 따라 시의 적절하게 함수 오버로딩 대신 Conditional Type 을 써봐도 좋을 것 같다.
챗 GPT의 답변에 따르면 간단한 경우에는 함수 오버로딩을, 복잡한 경우에는 Conditional Type 을 사용하는 것이 좋다고 한다.


[ Solution ]

  • fiterPerson()

1. Function Overloading

함수 파라미터에 들어갈 타입을 알고 있고, 파라미터 타입과 함수의 로직이 반복된다면 함수 오버로딩을 사용할 수 있다.

criteria 의 type 또한 명시적으로 지정해주도록 한다. unknown은 모든 값에 대한 타입의 상위 타입으로 어떤 타입이 할당되었는지 알 수 없음을 나타내기 때문에 unknown 타입으로 선언된 변수는 값을 가져오거나 내부 속성에 접근할 수 없다.

export function filterPersons(persons: Person[], personType: User['type'], criteria: Partial<User>): User[];
export function filterPersons(persons: Person[], personType: Admin['type'], criteria: Partial<Admin>): Admin[];
export function filterPersons(persons: Person[], personType: Person['type'], criteria: Partial<Person>): Person[] 
{
  return persons
    .filter((person) => person.type === personType)
    .filter((person) => {
      // criteriaKeys가 Person 인터페이스에 속하는 키들의 배열임을 단언
      let criteriaKeys = Object.keys(criteria) as (keyof Person)[];    
      return criteriaKeys.every((fieldName) => {
        return person[fieldName] === criteria[fieldName];
      });
    });
}

  • getObjectKeys

명시적인 캐스팅 없이 각 객체의 속성 키를 추출할 수 있도록 Generics 사용.

const getObjectKeys = <T>(criteria: T) => Object.keys(criteria) as (keyof T)[];

...

export function filterPersons(persons: Person[], personType: Person['type'], criteria: Partial<Person>): Person[] 
{
  return persons
    .filter((person) => person.type === personType)
    .filter((person) => {
      let criteriaKeys = getObjectKeys(criteria);  
      ...
    });
}

📌 저는 타입스크립트에서 무분별한 <유니온> 사용을 경계해야 한다고 생각합니다.

📌 Generics vs Function Overloading vs Union Type Arguments in TypeScript

📌 Overloads & Conditional Types in TypeScript


exercises7 : tuple type



exercises8 : intersection


[ Analayze ]

다중 인터페이스 상속이 필요하다. 타입스크립트에서 다중 인터페이스 상속은 두가지 방법으로 할 수 있다. intersection typeinterface extends다.

이름에서 알 수 있듯, 하나는 type을 사용하고 다른 하나는 interface를 사용한다. 공통점은 슈퍼 타입을 더 확장한 서브 타입을 만들 수 있다는 것이다.

1. intersection type

  • a & b 의 형태
  • 여러가지 타입을 결합하여 하나의 단일 타입으로 만들 수 있다. ( ※ 교집합의 개념)
  • 교차 타입이 어떤 값도 속하지 않는 타입을 생성할 수 있다. 같은 속성에 대해 서로 호환되지 않는 타입이 선언되면 never로 해석되어 빈 집합을 나타낸다.

2. interface extends

  • interface a extends b {} 의 형태
  • 인터페이스 간의 상속 관계를 표현.
  • 새로운 타입이 기존 타입의 서브타입이어야 함을 강제한다. 즉, 새로운 인터페이스는 기본 타입에서 정의한 집합의 하위 집합이어야 한다.

interface extends의 경우 상위 호환성을 만족하지 않으면 에러를 개발자에게 보여주며 실수를 줄여주지만, intersection type의 경우 never로 타입을 추론해 버리기 때문에 그런 게 없다. 그냥 무조건 상속이 성공하니 전체 타입 검사를 해야만 문제를 알아 낼 수 있다는 단점이 있다.

또 한, intersection type은 타입 확인을 할 때 의미론적으로 더 복잡하며 성능이 떨어지는 이슈가 있다고 한다.

여러모로 명확한 제약 조건을 제공하는 interface extends 사용이 권장되고 있었다.

📌 Type safety difference between type and interface when extending


[ Solution ]

연습 문제의 조건이 되는 UserAdmin 의 공통 필드 타입들은 모두 서로 호환되기 때문에 intersection type 과 interface extends 두 방법 모두 타입 검사에 문제는 없다.

모범 답안은 intersection type 을 사용하고 있었고, 개발자들이 권장하는 방법은 interface extends 였다.

1. intersection type

type PowerUser = Omit<User, 'type'> & Omit<Admin, 'type'> & {
    type: 'powerUser'
};

2. interface extends

interface PowerUser extends Omit<User, 'type'>, Omit<Admin, 'type'> {
    type: 'powerUser';
}

exercises9 : generics


[ Analayze ]

실제 현업에서 가장 많이 제네릭이 활용될 때가 API 응답 값의 타입을 지정할 때라고 한다. 부득이한 상황을 제외하고 복잡한 제네릭은 의미 단위로 분할해서 사용하도록 한다. 만약 내가 작성한 코드를 다른 개발자가 쉽게 이해하지 못하고 있다면 제네릭을 오남용하고 있는 것은 아닌지 검토해봐야 한다.


[ Solution ]

여러 타입을 상속받을 수 있으며 타입 매개변수를 여러 개 둘 수 있는 확장된 제네릭을 사용한다.

API 응답 값에 달라지는 data를 제네릭 타입 Data로 선언했다.

export type ApiResponse<Data> =
    | { status: 'success'; data: Data; }
    | { status: 'error'; error: string; };

...

export function requestAdmins(callback: (response: ApiResponse<Admin[]>) => void) {
	...
}

export function requestUsers(callback: (response: ApiResponse<User[]>) => void) {
	...
}

export function requestCurrentServerTime(callback: (response: ApiResponse<number>) => void) {
	...
}

export function requestCoffeeMachineQueueLength(callback: (response: ApiResponse<number>) => void) {
	...
}
    

0개의 댓글