[TypeScript] Advanced Types(고급 타입)

zeros0623·2020년 1월 8일
12

TypeScript

목록 보기
3/3
post-thumbnail

🙌안녕하세요🙌

요즘 타입스크립트 스터디를 진행하고있습니다.
일단 공식 도큐멘트를 한번 훑기로 했는데요, 제가 고급타입 부분을 맡게되어서
발표 준비 겸, 기록으로 남기기위해 포스팅합니다!

그럼 시작합니다!🚗💨

교차 타입(Intersection Types)

첫번째는 교차타입입니다!
다양한 타입을 하나로 결합해서 모든 기능을 갖춘 단일 타입을 얻는 방식입니다.

예를 들어, Person & Serializable & LoggablePerson,Serializable,Loggable의 모든 멤버를 가집니다.

믹스인(다른 포스팅에서 자세히 다룰 예정입니다)에 사용되는 교차타입, 그리고 우리가 알고 있던 객체지향의 방식과 다른 경우를 볼 수 있을 것입니다.

아래는 믹스인을 만드는 간단한 예제입니다.(짧게 설명하자면, 믹스인은 서로 다른 두 객체를 섞어서 두 객체의 기능을 모두 갖춘 하나의 객체를 만드는 것입니다 - 교차타입의 개념과 비슷하죠?)

function extend<T, U>(first: T, second: U): T & U {
    let result = <T & U>{}; // 교차 타입
    for (let id in first) {
        (<any>result)[id] = (<any>first)[id];
    }
    for (let id in second) {
        if (!result.hasOwnProperty(id)) {
            (<any>result)[id] = (<any>second)[id];
        }
    }
    return result;
}

class Person {
    constructor(public name: string) { }
}
interface Loggable {
    log(): void;
}
class ConsoleLogger implements Loggable {
    log() {
        // ...
    }
}
var jim = extend(new Person("Jim"), new ConsoleLogger());
var n = jim.name;
jim.log();

유니온 타입(Union Type)

유니온 타입은 이름으로 유추해 보았을 때 오히려 교차타입의 동작을 따라야 할 것 같습니다. 하지만 유니온 타입의 동작은 교차타입의 그것과는 좀 다릅니다.
간단하게 표현할 수 있습니다. 이 글을 보시는 여러분들은 |(pipeline)으로 대치되는 or이란 키워드를 알고계시죠??
한글로 말하자면 '혹은'이라고하지요, 유니온 타입은 이 단어를 적용한 경우와 같습니다.
string | number라고하면 string 혹은 number겠죠? 이것이 하나의 타입이 되는 것입니다.

유니온타입은 모든 타입의 공통적인 멤버에만 접근할 수 있습니다.

interface Bird {
    fly();
    layEggs();
}

interface Fish {
    swim();
    layEggs();
}

function getSmallPet(): Fish | Bird {
    // ...
}

let pet = getSmallPet();
pet.layEggs(); // 좋아요
pet.swim();    // 오류

유니온타입은 Fish 혹은 Bird 중에 하나라는 뜻으로 사용하니까 두 경우 전부 호환되기 위해선 공통된 멤버만 사용되어야 하겠죠? 만약 swim이라는 메소드를 사용했는데 타입이 Bird라면 메소드가 존재하지 않아 문제가 생길테니까요!

타입 가드와 차별된 타입 (Type Guards and Differentiating Types)

유니온 타입을 활용해서 두 타입 중 어느 타입이 들어오든 공통 된 메소드를 실행시켜야 할 경우를 커버할 수 있습니다. 그렇다면 어느 쪽 타입이 가지고 있지 않은 메소드를 실행해야할 떄는 어떻게 하면 좋을까요??

일반적으로 JavaScript에서는 메소드의 유무를 확인해서 실행할 수 있을 것입니다.
바로 이렇게요!

let pet = getSmallPet();

// 이러한 각 프로퍼티 접근은 오류를 발생시킵니다.
if (pet.swim) {
    pet.swim();
}
else if (pet.fly) {
    pet.fly();
}

그러나, 타입스크립트에서는 컴파일 타임에 오류가 발생하게됩니다.
pet의 타입은 Fish | Bird이니 .swim이나 .fly메소드를 사용할 수 없기때문이죠.
그럼 어떻게하면 좋을까요? 우리는 타입 단언을 사용해서 이 문제를 해결할 수 있습니다!

let pet = getSmallPet();

if ((<Fish>pet).swim) {
    (<Fish>pet).swim();
}
else {
    (<Bird>pet).fly();
}

사용자 정의 타입 가드(User-Defined Type Guards)

바로 위에서 우리는 타입 단언을 사용해서 어느 한 쪽 타입에만 존재하는 메소드를 사용할 수 있도록 코드를 작성하였습니다. 그런데 작동은하지만...타입 단언을 너무 많이 사용해서 코드가 이쁘지않아요😢😢

타입스크립트에서는 다행히도 타입 가드(type guard)라는 것을 지원합니다!
설명을 가져와서 읽어보자면,

일부 스코프에서 타입을 보장하는 런타임 검사를 수행하는 표현식.
타입 가드를 정의하려면 반환 타입이
타입 명제(type predicate) 인 함수를 정의하기만 하면 됩니다.

이라고 하네요. 한국말인데도 외계어처럼 느껴집니다...😂😂
그냥 저는 이렇게 이해했습니다. 타입을 보장해주는 쓸모있는 친구!
예제를 보면서 감을 잡아볼까요?

function isFish(pet: Fish | Bird): pet is Fish {
    return (<Fish>pet).swim !== undefined;
}

반환타입이 되게 낯설죠? 주석도 아닌데 왜 갑자기 영어 문장이....
저 낯선 친구가 위에 나온 타입 명제라는 친구입니다. xx는 xx다의 형식으로 사용해요.
타입 명제는 argName is Type의 형태로 작성하고, argName은 꼭 현재 함수에서 사용한 매개변수의 이름을 사용해야 합니다.

이제 이 타입가드를 이용하면 코드를 조금 더 깔끔하게 작성할 수 있습니다.

// 'swim'과 'fly' 호출은 이제 모두 괜찮습니다.

if (isFish(pet)) {
    pet.swim();
}
else {
    pet.fly();
}

헷갈릴때는 타입가드의 return 값이 true이면 명제가 옳다는 것으로 인식한다라는 점을 기억하세요.
isFish(pet)true이면 petFish다! 이해에 도움이 되셨나요❓
이 조건을 타입스크립트가 이해하기때문에 위의 코드에서 pet.swimpet.fly부분에
서 컴파일 오류가 사라지게 되는겁니다.

typeof 타입 가드(typeof type guards)

typeof를 사용한 타입가드 또한 작성이 가능합니다!
위에서 작성한 코드를 기준으로 만들어봅시다!

function isNumber(x: any): x is number {
    return typeof x === "number";
}

function isString(x: any): x is string {
    return typeof x === "string";
}

function padLeft(value: string, padding: string | number) {
    if (isNumber(padding)) {
        return Array(padding + 1).join(" ") + value;
    }
    if (isString(padding)) {
        return padding + value;
    }
    throw new Error(`Expected string or number, got '${padding}'.`);
}

타입가드는 작동하겠지만... 뭔가 타입마다 저렇게 만들어주어야하는게 굉장히 귀찮은 작업일 것 같네요💦💦
이런 단순한 반복작업을 피하기위해, 타입스크립트에서는 인라인 검사를 통해서 타입가드를 지원합니다!

인라인이라 함은, 함수의 내용만을 그대로 옮겨놓은 듯이 작성하는 것을 말합니다.

function padLeft(value: string, padding: string | number) {
    if (typeof padding === "number") {
        return Array(padding + 1).join(" ") + value;
    }
    if (typeof padding === "string") {
        return padding + value;
    }
    throw new Error(`Expected string or number, got '${padding}'.`);
}

작성해야할 코드가 많이 줄었는데 여전히 타입가드는 정상적으로 작동할 것입니다.
typeof키워드를 활용한 타입가드는
typeof v === "typename" 혹은 typeof v !== "typename"의 두가지 형태를 지원합니다.
typename[number, string, boolean, symbol]중의 하나이어야합니다.
물론 다른 문자열도 가능합니다만, 타입가드로 인식하지는 않습니다.

instanceof 타입 가드(instanceof type guards)

class같은 경우는 typeof를 사용해도 항상 "object"라는 값 만을 얻을 수 있을 것입니다.
이때, 우리는 좌절하지않고 instanceof키워드를 사용하면 됩니다!

interface Padder {
    getPaddingString(): string
}

class SpaceRepeatingPadder implements Padder {
    constructor(private numSpaces: number) { }
    getPaddingString() {
        return Array(this.numSpaces + 1).join(" ");
    }
}

class StringPadder implements Padder {
    constructor(private value: string) { }
    getPaddingString() {
        return this.value;
    }
}

function getRandomPadder() {
    return Math.random() < 0.5 ?
        new SpaceRepeatingPadder(4) :
        new StringPadder("  ");
}
// 여기까지는 단순히 랜덤한 인스턴스를 만들기 위한 밑작업입니다.

// 'SpaceRepeatingPadder | StringPadder' 타입입니다
let padder: Padder = getRandomPadder();

if (padder instanceof SpaceRepeatingPadder) {
    padder; // 타입이 'SpaceRepeatingPadder'로 좁혀졌습니다

if (padder instanceof StringPadder) {
    padder; // 타입이 'StringPadder'로 좁혀졌습니다.
}

코드가 길지만 핵심은 instanceof를 사용하는 부분입니다.

작성방식은variableName instanceof constructorName.

  1. 타입이 any가 아닌 경우 함수의 prototype 프로퍼티 타입
  2. 해당 타입의 생성자 시그니처에 의해 반환된 타입의 결합

위의 두 방식을 저는 이렇게 이해했습니다.

class Name{}

let name: any = new Name();

name.constructor.name === Name.name // true
//혹은
name.constructor.name | new Name().constructor.name // Name | Name이 됩니다.
// 그리고 Name 하나로 합쳐지게됩니다.

이 방식은 제가 이해한 방식일 뿐입니다. 매우 주관적이니 참고만 하시기 바랍니다🤷🤷

Nullable types

기본적으로 타입체커는 nullundefined를 모든 항목에 할당 가능한 것으로 판단합니다.
타입스크립트에서는 --strictNullChecks라는 컴파일 옵션이 있는데, --noImplicitAny와 함께 필수로 써야할 옵션으로 꼽힙니다.
--strictNullChecks 옵션을 설정한 채로 null값이 들어갈 수 있는 타입을 지정하려면 유니온 타입을 설정해야합니다
let a: string | null; 이런식으로요!

선택적 매개변수와 프로퍼티(Optional parameters and properties)

--strictNullChecks를 선택적 매개 변수와 함께 쓰면 자동으로| undefined를 추가합니다:

function f(x: number, y?: number) {
    return x + (y || 0);
}
f(1, 2);
f(1);
f(1, undefined);
f(1, null); // 오류, 'null'은 'number | undefined'에 할당할 수 없습니다

선택적 프로퍼티도 동일합니다.

class C {
    a: number;
    b?: number;
}
let c = new C();
c.a = 12;
c.a = undefined; // 오류, 'undefined'를 'number'에 할당 할 수 없습니다
c.b = 13;
c.b = undefined; // ok
c.b = null; // 오류, 'null'은 'number | undefined'에 할당할 수 없습니다

타입 가드와 타입 단언(Type guards and type assertions)

Nullable 타입은 유니온으로 구현되기 때문에 타입 가드를 사용하여 null을 제거해야합니다.

function f(sn: string | null): string {
  // case 1
    if (sn == null) {
        return "default";
    }
    else {
        return sn;
    }
  
  
  // case 2
  return sn || "default";
}

컴파일러가 null 또는 undefined를 제거할 수 없는 경우에는 타입 단언 연산자를 사용하여 수동으로 제거해야합니다.
연산자는 후위 !입니다. v!은 v의 null과 undefined를 제거합니다.

function broken(name: string | null): string {
  function postfix(epithet: string) {
    // 그러나 name이 null일 수 있다는 오류가 나옵니다.
    return name.charAt(0) + '.  the ' + epithet; // 오류, 'name'이 null일 수 있습니다.
  }
  // name이 null이어도 여기서 "Bob"으로 바뀝니다.
  name = name || "Bob";
  return postfix("great");
}


// 아래와 같이 느낌표를 넣어주면 됩니다.
function fixed(name: string | null): string {
  function postfix(epithet: string) {
    return name!.charAt(0) + '.  the ' + epithet; // 좋아요
  }
  name = name || "Bob"; 
  return postfix("great");
}

컴파일러가 namenull일 수 있다고 판단하는 이유는, 외부 함수에서 호출한 경우 중첩된 함수에 대한 모든 호출을 추적하는 것이 불가능하기 때문입니다(즉시실행함수-IIFE의 경우 가능).

IIFE를 적용해서 이렇게 코드를 작성할 수도 있습니다.

function broken(name: string | null): string {
    name = name || "Bob";
    return (function postfix(epithet: string) {
      // 즉시실행함수이기때문에 name이 null이 아니라는 것을 알고있음
        return name.charAt(0) + '.  the ' + epithet;
    })("great")
}

타입 별칭 (Type Aliases)

타입 별칭은 타입의 새로운 이름을 생성합니다.
그냥 타입에 다른 이름을 달아준다고 생각하면 될 거 같아요!

type Name = string;
type NameResolver = () => string;
type NameOrResolver = Name | NameResolver;
function getName(n: NameOrResolver): Name {
    if (typeof n === "string") {
        return n;
    }
    else {
        return n();
    }
}

실제로 새로운 이름을 생성하는 것이아니라 새로운 이름만 만들어 주는 것입니다.

인터페이스와 마찬가지로, 타입 별칭도 제네릭이 될 수 있습니다.

type Container<T> = { value: T };

타입 별칭을 스스로 참조할 수도 있습니다.

type Tree<T> = {
  value: T;
  left: Tree<T>;
  right: Tree<T>;
}

교차타입과 함께 꽤 쓸모있는 타입을 만들 수도 있습니다.

type LinkedList<T> = T & { next: LinkedList<T> };

interface Person {
    name: string;
}

var people: LinkedList<Person>;
var s = people.name;
var s = people.next.name;
var s = people.next.next.name;
var s = people.next.next.next.name;

그러나 타입 별칭이 표현식에 다시 들어가는 것은 불가능합니다❌

type Yikes = Array<Yikes>; // Error

Interfaces vs Type Aliases

인터페이스를 이미 공부하신 분들이라면, 이거 인터페이스와 비슷한데? 라는 생각을 하셨을지도 모르겠습니다!
이 비슷한 두 친구는 거의 비슷하지만 몇가지 다른 점을 가졌습니다.
일단, 아래코드를 에디터에 옮겨주세요.

type Alias = { num: number }
interface Interface {
    num: number;
}
declare function aliased(arg: Alias): Alias;
declare function interfaced(arg: Interface): Interface;

이렇게 같은 방식으로 사용할 수 있지만, 마우스 커서를 AliasInterface에 올려보시면 다른 점이 보이실 겁니다. 하나는 type Alias = { num: number }가 나오고,
하나는 interface Interface가 나올 겁니다.
인터페이스는 어디서나 사용되는 새로운 이름을 만들기 때문인데요, 이런 이유로 만약에 타입별칭에서 오류가 생기면 오류메시지에는 타입별칭이 사용되지 않을 것입니다.

또한, 가장 중요한 차이점으로 꼽을 수 있는 확장성 부분에서 차이가 큰데요,
객체지향에서 자주 사용되는 확장(extends), 구현(implements)등이 안된다는 겁니다.
스스로 확장,구현을 할 수도 없고 다른타입에서도 확장, 구현이 불가능합니다

type B = { num: number };
interface C { num: number};
interface A extends B {}; // Error
interface A extends C {}; // OK
type B extends C {}; // Error
type B implements C = {}; //Error

이런 점 때문에, 무조건 interface를 쓰는 것이 낫다는 의견도 있습니다.
하지만, 튜플 혹은 유니온 타입의 경우에는 aliases가 유용할 때도 있습니다.
상황에 맞게 사용해주시면 되겠습니다!

type tuple = [number, string];
type yesOrNo = 'yes' | 'no';

문자열 리터럴 타입(String Literal Types)

문자열 리터럴 타입은, "문자열을 이용한 타입" 정도로 설명할 수 있습니다.

type Easing = "ease-in" | "ease-out" | "ease-in-out";
let a: Easing = "uneasy" // Error 

바로 위에서 나온 yesOrNo의 예제랑 똑같이 생겼죠?

메소드 오버로드를 구현하기 위해 사용할 수도 있습니다.

function createElement(tagName: "img"): HTMLImageElement;
function createElement(tagName: "input"): HTMLInputElement;
// ... 더 많은 오버로드 ...
function createElement(tagName: string): Element {
    // ... 코드는 여기에 있습니다 ...
}

숫자 리터럴 타입(Number Literal Types)

문자열 리터럴과 같습니다. 더 설명할 부분이 없네요🤷

function rollDie(): 1 | 2 | 3 | 4 | 5 | 6 {
    // ...
}

열거형 멤버 타입(Enum Member Types)

열거형 멤버는 열거형이 선언되는 순간 하나의 타입이 됩니다.

식별 유니온(Dicriminated Unions)

싱글톤 타입, 유니온 타입, 타입 가드 및 타입 별칭을 결합하여 식별 유니온이라는 패턴을 만들 수 있습니다.
구성 요소는 세가지입니다.

  1. 공통적이고, 싱글톤 타입 프로퍼티를 가지는 타입 - discriminant
  2. 그 타입들의 유니온 타입 - the union
  3. 공통적인 프로퍼티를 사용한 타입가드
interface Square {
    kind: "square";
    size: number;
}
interface Rectangle {
    kind: "rectangle";
    width: number;
    height: number;
}
interface Circle {
    kind: "circle";
    radius: number;
}

첫번째 구성요소는 위의 kind 프로퍼티 처럼 공통적이고 각각 식별자로 이용할 수 있는 타입을 말합니다.

type Shape = Square | Rectangle | Circle;

두번째 구성요소는 그 타입들을 유니온해서 만든 하나의 타입입니다.

function area(s: Shape) {
    switch (s.kind) {
        case "square": return s.size * s.size;
        case "rectangle": return s.height * s.width;
        case "circle": return Math.PI * s.radius ** 2;
    }
}

세번째 구성요소는 위의 두가지 요소를 이용하는 타입가드 입니다.

식별유니온은 하나의 타입이라기 보다는, 타입가드를 활용하는 하나의 디자인 패턴이라고 할 수 있겠습니다

엄격한 검사(Exhaustiveness checking)

위에서 타입가드를 사용해서 검사한 코드를 다시 한번 볼까요?

function area(s: Shape) {
    switch (s.kind) {
        case "square": return s.size * s.size;
        case "rectangle": return s.height * s.width;
        case "circle": return Math.PI * s.radius ** 2;
    }
}

자 여기서 새로운 타입이 추가되었다고 가정해보겠습니다🤔🤔

type Shape = Square | Rectangle | Circle | Triangle;

그렇다면 우리는 area 함수도 바꿔주어야 할 것입니다.

두가지 방법이 있습니다.

  1. --strictNullChecks 컴파일 옵션을 설정하고, area함수에 반환타입을 추가하는 것.
  2. 컴파일러가 철저하게 검사하게 하기위해 never 타입을 사용하는 것.

일단 첫 번째 방법부터 볼까요?

function area(s: Shape): number { // error: returns number | undefined
    switch (s.kind) {
        case "square": return s.size * s.size;
        case "rectangle": return s.height * s.width;
        case "circle": return Math.PI * s.radius ** 2;
    }
}

이렇게 작성하면 number 부분에서 오류가 발생할 것입니다. 함수가 number | undefined라는 문구와 함께 말이죠

이 방법은 유니온 타입가드가 더 이상 모든 케이스를 커버할 수 없다는 것을 TypeScript가 알기 때문에 검사가 가능해지는 것입니다. 그러나 strictNullChecks가 오래된 코드에서 항상 작동하는 것은 아니라는 점을 주의해야합니다!.

두 번째 방법은 never타입을 이용한 방법입니다.

function assertNever(x: never): never {
    throw new Error("Unexpected object: " + x);
}
function area(s: Shape) {
    switch (s.kind) {
        case "square": return s.size * s.size;
        case "rectangle": return s.height * s.width;
        case "circle": return Math.PI * s.radius ** 2;
        default: return assertNever(s); // 누락된 경우 여기에 오류가 발생합니다
    }
}

이렇게 작성하면 case가 모든 경우를 커버하지 못하면, 오류가 발생하기 때문에 철저하게 검사를 할 수 있습니다.

다형성의 this 타입(Polymorphic this types)

풀어서 얘기하자면, 다양한 모습을 가지는 this 타입 정도가 되겠네요!
이미 JavaScript에서 this 키워드에 대해서 알고 오셨을거라 생각합니다.

JavaScript에서 보통 현재 컨텍스트(문맥 - 코드 흐름에서의 위치) 정도로 알고있을텐데요,
class 에서 this는 여타 객체지향언어들의 this와 같은 개념을 동작합니다.
생성자를 통해 만들어진 현재 인스턴스를 가리킵니다.

class BasicCalculator {
    public constructor(protected value: number = 0) { }
    public currentValue(): number {
        return this.value;
    }
    public add(operand: number): this {
        this.value += operand;
        return this;
    }
    public multiply(operand: number): this {
        this.value *= operand;
        return this;
    }
    // ... 다른 연산은 여기에 있습니다 ...
}

class ScientificCalculator extends BasicCalculator {
    public constructor(value = 0) {
        super(value);
    }
    public sin() {
        this.value = Math.sin(this.value);
        return this;
    }
    // ... 다른 연산은 여기에 있습니다 ...
}

let v1 = new BasicCalculator(2)
        .multiply(5)
        .add(1)
        .currentValue();
        
let v2 = new ScientificCalculator(2)
        .multiply(5)
        .sin()
        .add(1)
        .currentValue();

에디터에서 v1.multiply(5) 부분에 마우스를 올려보면 다음과 같습니다.

.multiply() 메소드는 this를 리턴하는데 리턴 타입이 BasicCaculator이네요?
v2도 한번 볼까요?

리턴 타입이 ScientificCaclulator입니다. 이렇게 this가 달라지는 것을 볼 수 있습니다.

인덱스 타입(Index types)

인덱스 타입을 사용하면 동적 프로퍼티 이름을 사용하는 코드를 컴파일러가 검사하도록 할 수 있습니다.

function pluck<T, K extends keyof T>(o: T, names: K[]): T[K][] {
  return names.map(n => o[n]);
}

interface Person {
    name: string;
    age: number;
}
let person: Person = {
    name: 'Jarid',
    age: 35
};
let strings: string[] = pluck(person, ['name']);

컴파일러는 name이 실제로 Person의 프로퍼티인지 확인합니다. 이 코드에는 두 가지 새로운 연산자가 등장합니다.

  1. 인덱스 타입 쿼리 연산자 - keyof T
  2. 인덱스 접근 연산자 - T[K]

첫 번째, 인덱스 타입 쿼리 연산자는 타입 T에 대해 알려진 프로퍼티 이름들의 유니온 타입입니다.
예제를 보겠습니다.

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

let props: keyof Person;

일 때, 에디터에서 props에 마우스를 올려보면 뭐가 나올지 예상이 되십니까?
위의 설명대로라면 아마 "name" | "age" | "sex"가 될 것입니다.

예상한대로 동작하는 것을 볼 수 있습니다.

두 번째는 인덱스 접근 연산자 T[K]입니다.
위에 나온 예제를 보시면 pluck의 리턴 타입은 T[K]의 형태로 되어있는 걸 알 수 있습니다.

그렇다면 pluck<T, K extends keyof T>(o: T, name: K[]): T[K][]인 제네릭에서 pluck(person, ['name'])로 함수를 호출하면 어떤 결과가 될까요?
T는 person의 타입인 Person, K[]"name"[]을 가지게 됩니다.
더 나아가면 T[K]Person["name"]이 되겠네요, 위 예제에서 Person["name"]string타입이죠? 그러면 결과적으로 pluck의 리턴타입은 string[]이 되는 것입니다.

이렇게 키 값에 따라 string[]

혹은 number[]의 리턴타입을 가지게 됩니다.

잠깐✋
이렇게 사용할 때에는 타입 변수 K extends keyof T를 꼭 선언해주어야 합니다.
이 점에 유의해주세요❗️

인덱스 타입과 문자열 인덱스 시그니처(Index types and string index ignatures)

문자열 인덱스 시그니처를 기억하고 계시나요
인덱서(인덱스를 나누는 키?)를 문자열로 고정하는 지정된 형태입니다.

interface Map<T> {
  [key: string]: T; // [key: string]: type 이 문자열 인덱스 시그니처입니다.
}

위 코드는 키 값은 string타입이고 값의 타입은 T라는 의미인데요,
그럼 keyof Map<type>은 무조건 string타입을 가지게 되겠죠? 키의 타입은 무조건 string이니까요

let keys: keyof Map<number>; // string
let values: Map<number>['asdf']; // number

Mapped types

타입을 만들다보면 프로퍼티들이 선택적 프로퍼티이거나 혹은 readonly일 때가 있습니다.
모든 프로퍼티의 타입을 선택적으로 하거나 readonly로 만들 수 있습니다.
한가지로 전부 매핑하는 것입니다.

잠깐✋ 이 예제의 ReadonlyPartial은 이미 TypeScript에 내장되어있기때문에 그대로 사용하시면 name conflict가 일어납니다
Readonly1등으로 바꾸어 사용해주세요❗

type Readonly<T> = {
    readonly [P in keyof T]: T[P];
}
type Partial<T> = {
    [P in keyof T]?: T[P];
}

type PersonPartial = Partial<Person>;
type ReadonlyPerson = Readonly<Person>;

let person2: PersonPartial = { name: 'John', sex: "male" } // 모든 프로퍼티가 선택적, age 프로퍼티가 없지만 통과
let person3: ReadonlyPerson = { name: 'Henry', age: 32, sex: 'male' }
person3.age = 33;

person은 위에서 썼던 예제인 Person을 그대로 가져다 쓴 것입니다.

간단한 mapped type으로 동작 원리를 한번 볼까요❓

type Keys = 'option1' | 'option2';
type Flags = { [K in Keys]: boolean };

K는 Keys의 타입을 차례로 읽어옵니다. ex) option1, option2
for .. in구문의 동작과 유사하다고 생각하시면 될 것 같습니다.

매핑이 되면 아래 코드와 같게 됩니다.

type Flags = {
  option1: boolean;
  option2: boolean;
}

다음은 mapped types의 가장 일반적인 템플릿이며 대부분의 경우에 적합합니다

type Nullable<T> = { [P in keyof T]: T[P] | null }
type Partial<T> = { [P in keyof T]?: T[P] }

그리고 이 mapped type은 mapped 되기 전 원본의 프로퍼티 지정자(readonly, optional)등을 모두 복사합니다.

interface Person {
  name: string;
  readonly age: number;
  sex: "male" | "female";
}

type Partial<T> = { [P in keyof T]?: T[P] }

let partialPerson: Partial<Person> = { name: "John", age: 33, sex: "male" }; // 모든 프로퍼티는 partial(선택적)이고 age는 선택적이며 readonly이다

Inference from mapped types

위에서 프로퍼티를 매핑하는 법을 알았으니, 이제 언래핑도 해봐야겠죠❓

function unwrapReadonly<T>(t: Readonly<T>): T {
    let result = {} as T;
    for (const k in t) {
        result[k] = t[k]
    }
    console.log(result)
    return result;
}

let person2: PersonPartial = { name: 'John', age: 33 }
let person3: ReadonlyPerson = { name: 'Henry', age: 32, sex: 'male' }
let unwrappedPartial = unwrapReadonly(person2);
let unwrappedReadonly = unwrapReadonly(person3);

이렇게 작성했을 때, 우리는 리턴 타입이 Person이길 기대할겁니다.
그리고 인자의 타입이 Readonly<T>이니, 우리는 Partial<T>를 매개변수로 호출할 때 에러가 나길 기대하겠지만 에러없이 실행이 됩니다.

이 부분은 후에 문제가 될 소지가 있겠죠? 예측과 다르게 동작할 수 있다는 얘기입니다.
그럼 어떻게하면 좋을까요? 정답은 리턴타입을 변수에 명시해주는 것입니다.

let unwrappedPartial: Person = unwrapReadonly(person2); // Error
let unwrappedReadonly: Person = unwrapReadonly(person3); // OK


매개변수의 타입이 달랐기 때문에 리턴 타입 또한 보장받지 못하여 Person타입이 아니게 됩니다. 그래서 타입이 다르다는 오류를 출력하게 되죠.

타입을 명시하지않아도 타입스크립트는 동작할 수 있지만, 독이 될 수 있으므로 항상 타입을 명시해주는 습관을 들이는게 타입스크립트를 잘 사용하는 길입니다👍

조건부 타입(Conditional Types)

조건부 타입은 현재(2020.01.08) 핸드북 번역본에 들어가 있지 않은 단락입니다.
원문에서 발견하여 추가로 작성합니다.
TypeScript 2.8 버전에서 소개되었습니다.

번역본은 아니고, 원문의 내용과 검색으로 알게 된 내용을 믹스해서 작성하였습니다.

조건부 타입은 조건부 표현식에 따라 정해지는 타입니다.
여러분은 아래와 같은 표현식을 보신 적이 있으시겠죠?

a is true ? 'a' : 'b';

조건부 타입은 위 표현식을 활용한 타입입니다.
조건부 타입은 하나의 타입이라기보다, 조건을 만족하는 타입을 구하는 표현식이라고 보는게 옳을 것 같습니다

T extends U ? X : Y

위와 같은 형식으로 조건에 따른 타입을 지정할 수 있습니다.
위는 TU에 할당 가능하면 X, 아니면 Y 타입을 리턴한다는 의미입니다.

Type aliases와 제네릭을 이용한 간단한 예제입니다.

type TypeName<T> =
    T extends string ? "string" :
    T extends number ? "number" :
    T extends boolean ? "boolean" :
    T extends undefined ? "undefined" :
    T extends Function ? "function" :
    "object";

type T0 = TypeName<string>;  // "string"
type T1 = TypeName<"a">;  // "string"
type T2 = TypeName<true>;  // "boolean"
type T3 = TypeName<() => void>;  // "function"
type T4 = TypeName<string[]>;  // "object"

조건부 타입 T extends U ? X : YX | Y타입으로 해석되거나 혹은 해석이 유예됩니다(타입에 의존적이기 때문입니다.)

T 혹은 U가 타입변수를 포함하면, X | Y로의 타입해석 혹은 타입 해석 유예는 타입시스템이 "TU에 할당가능한가"에 대한 충분한 정보를 가졌는지 아닌지에 따라 정해집니다.

예시를 보면서 비교해보시죠.

declare function f<T extends boolean>(x: T): T extends true ? string : number;

// x는 'string | number' 타입을 가집니다
let x = f(Math.random() < 0.5)

위 경우는 이미 리턴 타입이 string | boolean으로 해석되는 경우입니다.

다음은 타입 해석이 연기되는 경우입니다.

interface Foo {
    propA: boolean;
    propB: boolean;
}

declare function f<T>(x: T): T extends Foo ? string : number;

function foo<U>(x: U) {
    // a는 'U extends Foo ? string : number' 타입을 가집니다
    // U의 타입을 알 수 없기 때문에 타입 해석이 연기됩니다.
    let a = f(x);

    // 그래도 b에 a를 할당하는 것은 가능합니다. 
    // 결과적으로 'string | number'가 될 것은 확실하기 때문입니다.
    let b: string | number = a;
}

분배 조건부 타입(Distributive conditional types)

검사가 이루어진 이후의 날 것의 타입인 조건부 타입을 분배 조건부 타입이라고 합니다.
분배 조건부 타입은 인스턴스화하는 동안 유니온 타입으로 분배됩니다.
예를 들어, T extends U ? X : Y이고 T의 타입 매개변수가 A | B | C라면,
이렇게 해석됩니다.
(A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y)

다음은 분배 조건부 타입이 해석되는 과정을 담은 예제입니다.

type EmailAddress = string | string[] | null | undefined;
type NonNullable1<T> = T extends null | undefined ? never : T;
type NonNullableAddress = NonNullable1<EmailAddress>
// 아래와 같이 분배됩니다.
type NonNullableEmailAddress = NonNullable<
  | string
  | string[]
  | null
  | undefined
    >;
// next
type NonNullableEmailAddress =
  | NonNullable<string>
  | NonNullable<string[]>
  | NonNullable<null>
  | NonNullable<undefined>;
// next
type NonNullableEmailAddress =
  | (string extends null | undefined ? never : string)
  | (string[] extends null | undefined ? never : string[])
  | (null extends null | undefined ? never : null)
  | (undefined extends null | undefined ? never : undefined);
// next
type NonNullableEmailAddress =
  | string
  | string[]
  | never
  | never;
// next
type NonNullableEmailAddress = string | string[];  

조건부 타입 T extends U ? X : Y의 인스턴스화 과정에서, 조건부 타입 내부의 T에 대한 참조는 유니온 타입의 각각의 요소로 해석됩니다. 그리고 X절(조건이 참인 경우) 에서 TU에 할당가능한 것으로 계산됩니다.

type BoxedValue<T> = { value: T };
type BoxedArray<T> = { array: T[] };
type Boxed<T> = T extends any[] ? BoxedArray<T[number]> : BoxedValue<T>;
// 조건이 참일 때, BoxedArray<T[number]>이 가능한 이유
// 이미 T extends any[] ? 라는 조건을 통과한 상태이기 때문에 T는 무조건 배열.

type T20 = Boxed<string>;  // BoxedValue<string>;
type T21 = Boxed<number[]>;  // BoxedArray<number>;
type T22 = Boxed<string | number[]>;  // BoxedValue<string> | BoxedArray<number>;

그러던중 재밌는 것을 발견했는데 type [][number] === type never라는 사실입니다,

interface stringIndex {
    [index: number]: any
}

let ccc: stringIndex[numbe] // any
let ddd: [][number] // never
let eee: ['1'][number] // '1'

[]number로 인덱싱하면 아무값도 얻을 수 없기 때문에 never인 것으로 이해했습니다.
결과로 보아 타입으로 해석할때 []['number']은 빈배열([])을 [number]로 인덱싱 하였을 때 얻을 수 있는 타입이 되는 것이라고 유추하였고,
['1', '2']number로 인덱싱한 결과가 리턴타입이 '1' | '2'이 되어서 검증되었다고 결론지었습니다.

매핑 타입과 함께 쓰는 조건부 타입(Conditional types with mapping types)

위에서 배운 매핑타입을 조건부 타입과 함께 사용해보겠습니다.
NonNullable이라는 타입을 분배 조건부 타입에서 예제를 통해 보셨었죠?
모든 프로퍼티를 매핑해주는 매핑타입을 조건부 타입과 함께 이용하면, 조건을 만족하는 프로퍼티들로 추려낼 수 있습니다.👍

이 또한 어떠한 과정을 통해 적용되는지 함께 보실까요❓

type NonNullablePropertyKeys<T> = {
  [P in keyof T]: null extends T[P] ? never : P
}[keyof T];
type User = {
  name: string;
  email: string | null;
};
// next
type NonNullableUserPropertyKeys = NonNullablePropertyKeys<User>;
// next
type NonNullableUserPropertyKeys = {
  [P in keyof User]: null extends User[P] ? never : P
}[keyof User];
// next
type NonNullableUserPropertyKeys = {
  [P in "name" | "email"]: null extends User[P] ? never : P
}[keyof User];
// next
type NonNullableUserPropertyKeys = {
  name: null extends User["name"] ? never : "name";
  email: null extends User["email"] ? never : "email";
}[keyof User];
// next
type NonNullableUserPropertyKeys = {
  name: null extends string ? never : "name";
  email: null extends string | null ? never : "email";
}[keyof User];
// next
type NonNullableUserPropertyKeys = {
  name: "name";
  email: never;
}[keyof User];
// next
type NonNullableUserPropertyKeys = {
  name: "name";
  email: never;
}["name" | "email"];
// next
type NonNullableUserPropertyKeys =
  | { name: "name"; email: never }["name"]
    | { name: "name"; email: never }["email"];
// next
type NonNullableUserPropertyKeys =
  | "name"
  | never;
// next
type NonNullableUserPropertyKeys = "name";

또한, 유니온 타입이나 교차 타입과 같이 재귀적으로 본인을 참조하는 것이 허용되지 않습니다.

type ElementType<T> = T extends any[] ? ElementType<T[number]> : T;  // Error

조건부 타입의 타입 추론(Type inference in conditional types)

조건부 타입에서는 extends절에서 infer키워드를 사용 가능합니다.
infer키워드는 타입변수를 참조하기 위해서 사용됩니다.
같은 타입변수에 다수의 infer 키워드를 사용할 수도 있습니다.
직관적인 구조이기 때문에 이해가 어렵지 않습니다.

기본적인 형태입니다.

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
type First<T> =
  T extends [infer U, ...unknown[]]
    ? U
    : never;

type SomeTupleType = [string, number, boolean];
type FirstElementType = First<SomeTupleType>; // string

// ReturnType은 이미 존재하는 이름입니다 수정해서 사용해주세요
type ReturnType<T> =
  T extends (...args: any[]) => infer R
    ? R
    : any;

type A = ReturnType<() => string>;         // string
type B = ReturnType<() => () => any[]>;    // () => any[]
type C = ReturnType<typeof Math.random>;   // number
type D = ReturnType<typeof Array.isArray>; // boolean

다음은 infer 키워드 여러개를 이용하는 예제입니다.

type Unpacked<T> =
    T extends (infer U)[] ? U :
    T extends (...args: any[]) => infer U ? U :
    T extends Promise<infer U> ? U :
    T;

type T0 = Unpacked<string>;  // string
type T1 = Unpacked<string[]>;  // string
type T2 = Unpacked<() => string>;  // string
type T3 = Unpacked<Promise<string>>;  // string
type T4 = Unpacked<Promise<string>[]>;  // Promise<string>
type T5 = Unpacked<Unpacked<Promise<string>[]>>;  // string

다음 예제는 공변적 포지션에 있는 같은 타입 변수의 여러 후보(infer로 참조 된)이 어떻게 유니온 타입이 되는지 보여줍니다.

type Foo<T> = T extends { a: infer U, b: infer U } ? U : never;
type T10 = Foo<{ a: string, b: string }>;  // string
type T11 = Foo<{ a: string, b: number }>;  // string | number

다음은, 반변적 포지션에 있는 같은 타입 변수의 여러 후보(infer로 참조 된)이 교차 타입이 되는 과정을 보여줍니다.

type Bar<T> = T extends { a: (x: infer U) => void, b: (x: infer U) => void } ? U : never;
type T20 = Bar<{ a: (x: string) => void, b: (x: string) => void }>;  // string
type T21 = Bar<{ a: (x: string) => void, b: (x: number) => void }>;  // string & number

공변성 & 반변성의 관한 포스팅은 이곳을 참조하였습니다.

공변적이라 함은 A <: B(A가 B의 SubType)일 때, 함수식 (T -> A) <: (T -> B)같이
좌항과 우항의 위치가 유지되는 것을 의미한다.

반변적이라 함은 A <: B(A가 B의 SubType)일 때, 함수식 (B -> T) <: (A -> T)같이
좌항과 우항의 위치가 바뀌는 것을 의미한다.

함수타입에서 반환타입은 공변적(co-variant)이고, 인자타입은 반변적(contra-variant)이다.

여러 여러 콜 시그니처(예를 들면 오버로드 된 함수의 타입)를 infer와 함께 사용할 때, 마지막으로 선언된 콜 시그니처로 부터 추론됩니다. 인자 타입 베이스의 오버로드 해석은 불가능합니다.

declare function foo(x: string): number;
declare function foo(x: number): string;
declare function foo(x: string | number): string | number;
type T30 = ReturnType<typeof foo>;  // string | number

infer 키워드를 제약조건 절(extends ...)에서 사용하는 것은 불가능합니다.

type ReturnType<T extends (...args: any[]) => infer R> = R;  // Error, not supported

그러나, 제약조건 절(extedns ...)에서 타입변수를 제거하고, 특정 조건부 타입으로 대체하는 것으로 거의 같은 효과를 낼 수 있습니다.

type AnyFunction = (...args: any[]) => any;
type ReturnType<T extends AnyFunction> = T extends (...args: any[]) => infer R ? R : any;

사전정의 된 조건부 타입(Predefined condtiional types)

Exclude<T, U>

T로부터 U에 할당 가능한 타입들을 제외한다.

Extract<T, U>

T로부터 U에 할당 가능한 타입들만 추출한다.

NonNullable

T로부터 null | undefined인 타입들을 제거한다.

ReturunType

T라는 함수타입의 리턴 타입을 얻는다.

InstanceType

T라는 생성자 함수의 인스턴스 타입을 얻는다.(== 어느 클래스인지)

이렇게 타입스크립트의 고급 타입 파트에 대해서 알아보았습니다!
포스팅이 이렇게 길어질 줄은 몰랐네요😢😢

다음 포스팅에서는 타입스크립트에서의 데코레이터에 대해서 알아보겠습니다
그럼, 다음포스팅에서 만나요 여러분👋👋

profile
주니어 개발자입니다. 풀스택 유니콘이 되고싶어요. 2020.02 ~ 루센트블록 재직

1개의 댓글

comment-user-thumbnail
2020년 5월 13일

좋은 글 잘 읽었습니다.
현시점에서는 type도 extends, implements모두 사용 가능하도록 변경되었습니다.

type test = {
    a: number;
}

interface inter extends test { // OK
    b: number
}

class Test implements inter { // OK
    public a = 1;
    public b = 1;
}

class Test2 implements test { // OK
    public a = 1;
}
답글 달기