• 타입스크립트 몰랐던 것 (1) :: 마이구미
    Typescript 2021. 10. 4. 18:26
    반응형
    이 글은 타입스크립트 예제 중심으로 다뤄지고 동작 이해를 위해 정리한 글이다.
    타입스크립트가 무엇인지? 왜 사용하는지? 에 대한 글은 아니다.
    "이펙티브 타입스크립트" 를 통해 알게되었거나 스스로 조금 더 다듬어놓기 위한 것들이다.
    이펙티브 타입스크립트 - http://www.yes24.com/Product/Goods/102124327

     

    타입스크립트를 사용한 시점부터 깊게 공부하지 않고 기본적인 개념만 가지고 사용했다.

    그 이유는 단순 타입 시스템 목적으로 그때그때 습득하면서 사용해도 크게 문제될 것이 없다고 판단했다.

    그 과정중 느꼈던 건, 다른 라이브러리들의 코드 분석이 필요할 때, 생각한대로 타입 선언과 추정이 되지 않을 때 조금 난감했다.

    그럴 경우에는 any 를 쓰거나 특정 부분은 타입스크립트를 비활성화 시켜버리곤 했다.

     

    complicatedFn(params: any) { ... }
    
    /* eslint-disable-next-line */
    
    /* eslint-disable */

     

    미루고 미루다가 타입스크립트 관련 책을 보게 되었다.

    역시나 이 책을 읽으면서 더 혼란을 느끼는 부분이 종종 발생했다.

    이것이 의미하는 것은 타입스크립트를 정확히 이해하고 있지 않고 사용했다는 것이 증명되었다.

     

    이번에 새로 알게되거나 조금 더 이해하고 있어야하는 것들을 정리해보았다.

    TS Playgroud 와 함께 확인해보면 좋다. (https://www.typescriptlang.org/play)

     

     

    자세한 내용은 더 많은 내용이 담겨져있는 책을 읽어보길 추천한다.


     

    제네릭 타입에서 매개변수에 extends 를 사용하는 것은 매개변수에 특정 타입을 확장한다고 선언할 수 있다.

     

    function getKey1<K extends string>(val: any, key: K) {
      return key;
    }
    
    function getKey2(val: any, key: string) { return key }
    
    getKey1({}, Math.random() < 0.5 ? 'a': 'b'); // 반환 타입 'a' | 'b'
    getKey2({}, Math.random() < 0.5 ? 'a': 'b'); // 반환 타입 string

     

    getKey1 의 매개변수 제네릭 타입에 string 을 확장함으로써, 반환 타입은 string 이 아닌 더 직관적으로 'a' | 'b' 로 확인할 수 있다.

     

     

    typeof 는 타입스크립트, 자바스크립트에 따라 사용 목적이 다를 수 있다.

     

    // javascript typeof
    if (typeof fn === 'fucntion') {}
    
    // typescirpt typeof
    const person = { name: 'mygumi', gender: 'male' };
    type Person = typeof person;
    const p: Person = { name: '', gender: '' }

     

    타입의 관점에서 타입스크립트의 타입을 반환한다.

    값의 관점에서 런타임의 typeof 연산자가 반환한다.

     

    객체 래퍼 타입을 지양하고 기본형 타입을 지향해야한다.

     

    우리는 자바스크립트에서 원시값 형태의 문자열 리터럴인 "string" 과 객체 래퍼 형태의 "String" 은 다르다는 것을 알고 있다.

    참고로 string 타입에서 String 에서 제공해주는 메소드들을 사용할 수 있는 이유는 문자열 리터럴(string)에서 메소드 사용 시 객체 타입 String 으로 래핑하여 메소드 호출 후 래핑한 객체 버리는 과정이 진행되기 때문이다.

     

    결론은 타입스크립트에서도 잘 인지해서 타입 선언을 해야한다.

     

    function isGreeting(phrase: String) {
      return [
        'hello',
      ].includes(phrase) // TypeError
    }

     

     

     

    객체 리터럴은 잉여 속성 체크를 수행한다.

     

    타입스크립트의 타입 시스템은 덕 타이핑(구조적 타이핑)을 따른다.

    덕 타이핑 - https://mygumi.tistory.com/367

     

    interface Room {
      numDoors: number;
      ceilingHeightFt: number;
    }
    
    const hotel = {
      numDoors: 1,
      ceilingHeightFt: 10,
      elephant: 'present'
    }
    
    const r: Room = hotel;

     

    Room 인터페이스에 elephant 가 없어도 hotel 객체는 타입 에러 없이 타입 체크를 통과한다.

    객체 리터럴 방식의 코드라면 잉여 속성 체크를 통해 elephant 타입 에러가 발생한다.

     

    const hotel: Room = {
      numDoors: 1,
      ceilingHeightFt: 10,
      elephant: 'present' // TypeError
    }

     

     

    함수 선언식보다는 함수 표현식에 타입 적용하기

     

    declare function fetch(input: RequestInfo, init?: RequestInit): Promise<Response>
    
    // 함수 선언식 => 전달 인자에 타입 설정
    async function checkedFetch(input: RequestInfo, init?: RequestInit) { ... }
    
    // 함수 표현식 => 전달 인자에 타입 설정없이 타입 추론 가능
    const checkedFetch: typeof fetch = async (input, init) => {
      const response = await fetch(input, init);
      if (!response.ok) {
        throw new Error()
      }
      return response;
    }

     

    함수 표현식은 전체 타입에 typeof fetch 를 명시해주면 Input, init 의 타입을 추론할 수 있게 해준다.

    위와 같은 상황을 보면 매개변수나 반환 값에 타입을 명시하기보다는 함수 표현식 전체에 타입 구문을 적용하는 것이 좋아보인다.

     

     

    type, interface 의 차이

     

    타입스크립트에서는 type 과 interface 키워드를 통해 타입을 정의할 수 있다.

    일반적인 경우에서는 별 차이없이 정의되고 동작되기에 두 개의 차이를 이해하지 않고도 사용할 수 있다.

     

    하지만 interface 는 유니온 타입의 확장이 불가능하다.

     

    interface Input { input:number }
    interface Output { output: number }
    
    type NamedVariable = (Input | Output) & { name: string }
    interface NamedVariable = ... // 표현 불가능
    
    const v: NamedVariable = { input: 1, name: '' };

     

    결국 복잡한 타입, 고급 기능을 위해서는 type 을 사용할 수밖에 없다.

    튜플의 경우에도 interface 를 통해 선언하기 위해서는 부자연스러운 모습을 볼 수 있다.

     

    type Pair = [number, number];
    interface Tuple {
      0: number;
      1: number;
      length: 2
    }

     

    하지만 interface 는 유용한 보강 기법이 존재한다.

     

    interface IState {
      name: string;
      capital: string;
    }
    
    interface IState {
      population: number;
    }
    
    const s: IState = {
      name: 'Korea',
      capital: 'Seoul',
      population: 50000000
    }

     

     

     

    타입을 명시하지 않으면, 타입 체커는 타입을 유추하여 결정해야한다.

     

    타입이 명시되지 않았을 경우, 타입 체커가 타입을 유추하는 과정을 "widening" 이라고 한다.

     

    interface Vector3D {x: number, y: number, z: number;}
    function getComponent(vector: Vector3D, axis: 'x' | 'y' | 'z') { return vector[axis] }
    
    const x1 = 'x'; // 반환 타입 'x'
    let x2 = 'x'; // 반환 타입 string
    let x3 = 'x' as const; // 반환 타입 'x'
    let obj1 = { x: 'x' } // 반환 타입 { x: string }
    let obj2 = { x: 'x' as const } // 반환 타입 { x: 'x' }
    const vec = { x: 10, y: 20, z: 30 };
    
    getComponent(vec, x1);
    getComponent(vec, x2); // TypeError
    getComponent(vec, x3);
    getComponent(vec, obj1.x); // TypeError
    getComponent(vec, obj2.x);

     

    const 활용하면 더 좁은 타입이 된다. (let 변수에도 as const 를 선언해줄 수 있다.)

    객체의 경우 각 요소들은 let 으로 할당된 것처럼 다뤄지기 때문에, as const 를 통해 타입을 강제 추론하게 해야한다.

     

     

    유니온의 인터페이스보다는 인터페이스의 유니온을 사용하기

     

    유니온의 인터페이스의 예를 보자.

     

    interface Layer {
      layout: FillLayout | LineLayout | PointLayout
      paint: FillPaint | LinePaint | PointPaint
    }

     

    위의 문제점은 { FillLayout, FillPaint }, { LineLayout, LinePaint }, { Paintayout, PointPaint } 와 같이 1:1 매칭이 되어야한다.

    하지만 위의 인터페이스 상에는 { FillLayout, LinePaint } 와 같은 형태로 매칭될 가능성이 존재한다.

     

    이를 위해 인터페이스의 유니온 + 태그된 유니온(tagged union) 을 활용한 예제이다. 

     

    interface FillLayer {
      type: 'fill';
      layout: FillLayout;
      paint: FillPaint
    }
    
    interface LineLayer {
      type: 'line';
      layout: LineLayout;
      paint: LinePaint
    }
    
    interface PointLayer {
      type: 'point';
      layout: PointLayout;
      paint: PointPaint
    }
    
    type Layer = FillLayer | LineLayer | PointLayer
    
    function drawLayer(layer: Layer) {
      if (layer.type === 'fill') {
        const { layout, paint } = layer; // FillLayer 타입 추론
      } else if (layer.type === 'line') {
        const { layout, paint } = layer; // LineLayer 타입 추론
      } else {
        const { layout, paint } = layer; // PointLayer 타입 추론
      } 
    }

     

     

    any 타입은 가능한 한 좁은 범위에서만 사용하기

     

    any 타입을 지양해야하겠지만 사용해야한다면 다른 곳에 영향이 없어야한다.

     

    interface Foo { foo: any }
    interface Bar { bar: any }
    function expressionReturingFoo (): Foo { return { foo: '' } }
    function processBar (value: Bar) { return {} }
    
    function fn() {
      const x = expressionReturingFoo();
      processBar(x); // TypeError
      return x;
    }

     

    x 값은 expressionReturingFoo 에 의해 Foo 타입이다.

    하지만 processBar 의 전달인자는 Bar 로 전달해야하기 때문에 타입 에러가 발생한다.

    이것을 해결하기 위해 x 의 타입을 any 로 선언해보자.

     

    function fn() {
      const x: any = expressionReturingFoo();
      processBar(x);
      return x;
    }
    
    fn(); // 반환 타입 any

     

    타입 에러는 피했지만, fn() 반환 타입이  any 로 설정되었다.

    x 에 대한 타입은 processBar() 를 호출할 시점에만 변환하고 다른 곳에서는 영향이 가지 않도록 설정할 수 있다.

     

    function fn() {
      const x = expressionReturingFoo();
      processBar(x as any);
      return x;
    }
    
    fn(); // 반환 타입 Foo

     

     

    any 타입의 진화 이해하기

     

    noImplicitAny = true 설정 시에는 암시적 any 상태인 변수에 어떠한 할당도 하지 않고 사용하려고 하면 암시적 any 오류 발생한다.

     

    function range(start: number, limit: number) {
      const out = []; // TypeError
      
      return out; // TypeError
    }

     

    암시적 any 타입에 어떠한 값을 할당할 때 any 타입의 진화가 발생한다.

     

    function range(start: number, limit: number) {
      const out = [];
    
      for (let i = start; i < limit; i++) {
        out.push(i);
      }
    
      return out; // 타입 추론 number[]
    }

     

    out 변수는 암시적으로 any[] 타입을 가지고 있지만, 루프를 통해 number[] 타입으로 진화하게 되었다.

    다만, 아래 예제처럼 함수 호출을 거치는 것은 진화가 일어나지 않는다.

     

    function range(start: number, limit: number) {
      const out = []; // TypeError
      [1,2,3].forEach((v => { out.push(v) }));
      return out; // TypeError
    }

     

     

    any 대신 unknown 사용하기

     

    any 타입의 특징은 다음과 같다.

    1. 어떠한 타입이든 any 타입에 할당 가능하다.
    2. any 타입은 어떠한 타입으로도 할당 가능하다.

    unknown 타입의 경우 1번은 만족하지만, 2번은 만족하지 못한다.

     

    let unknownValue: unknown = 123;
    let anyValue: any = 123;
    let newValue = true;
    
    anyValue = '';
    unknownValue = '';
    
    newValue = anyValue;
    newValue = unknownValue; // TypeError

     

     

    제공되지 않는 타입 export 하기

     

    외부 라이브러리를 사용할 경우에 대부분의 타입을 제공해주겠지만 그 중 특정 타입을 제공해주지 않을 수 있다.

    본인은 그냥 무시하거나 직접 만들어서 사용하곤 했다.

    단순하게 생각하니 그 타입을 직접 다른 뽑는 것도 방법일 수 있다.

     

    interface SecretName {}
    interface SecretSanta {}
    
    export function getGift(name: SecretName, gift: string): SecretSanta {}

     

    위의 예제처럼 SecretName, SecretSanta 인터페이스가 export 로 선언되어있지않아 외부 라이브러리의 타입을 사용할 수 없다.

    이런 경우 export 할 수 있는 getGift 를 활용해서 뽑아낼 수 있다.

     

    type MySanta = ReturnType<typeof getGift>;
    type MyName = Parameters<typeof getGift>[0];

     

     

    타입도 조건부 연산자를 활용할 수 있다.

     

    function double(x: string | number): number | string;
    function double(x: any) { return x + x; }
    
    double('1'); // 반환 타입 number | string

     

    위 예제의 경우 반환 값은 '11' 로써, 우리가 원하는 반환 타입은 string 이다.

    하지만 number | string 으로 추론된다.

     

    이를 대응하기 위해 타입에도 조건부 연산자를 활용할 수 있다.

     

    function double<T extends number|string>(x: T): T extends string ? string : number;
    function double(x: any) { return x + x };
    
    double('1'); // 반환 타입 string
    double(1); // 반환 타입 number

     

    enum 보다는 문자열을 유니온 타입으로 사용하기

     

    타입스크립트에서 enum 의 경우 import 하고 문자열 대신 사용해야한다.

     

    enum Flavor {
      Vanilla = 'vanilla',
      Choclate = 'choclate',
      Strawberry =  'strawberry'
    }
    
    function scoop(v: Flavor) {}
    scoop('choclate'); // TypeError
    scoop(Flavor.Choclate);

     

    위 코드는 구조적 타이핑이 아닌 명목적 타이핑이 되어버린다.

    유니온 타입으로 선언하면 타입 추론과 자동 완성이 동일하게 지원되기에 아래의 코드가 더 자연스러워보인다.

     

    // 유니온 타입 1
    type Flavor = 'vanilla' | 'choclate' | 'strawberry'
    
    function scoop(v: Flavor) {}
    scoop('choclate');
    
    // 유니온 타입 2
    const FlavorType = {
      Vanilla: 'vanilla',
      Choclate: 'choclate',
      Strawberry:  'strawberry'
    } as const
    type Values<T> = T[keyof T];
    type Flavor = Values<typeof FlavorType>
    
    function scoop(v: Flavor) {}
    scoop("choclate")

     

     

    private 를 위한 기능을 3.8 버전부터 private field 를 제공해준다

     

    아시다시피 타입스크립트는 런타임 시점이 아닌 컴파일 시점이다.

    타입스크리븥에서 private 키워드를 제공해주더라도 런타임 시점에는 없어지기 때문에 원래의 목적인 private 목적을 이룰 수 없다.

     

    3.8 버전부터 private field 라는 용어로 제공해주고 있다.

     

    class Animal {
      #name: string;
      constructor(theName: string) {
        this.#name = theName;
      }
    }
     
    new Animal("Cat").#name; // #name 접근 불가 에러

     

    반응형

    댓글 0

Designed by Tistory.