이펙티브 타입스크립트 스터디(3)

오형근·2022년 8월 27일
0

Typescript

목록 보기
10/15
post-thumbnail

Effective Typescript에 대한 스터디 진행 내용을 요약한 것입니다.

편집기를 사용하여 타입 시스템 탐색하기

TS에서 제공하는 타입 체커와 타입추론은 강력하기 때문에 다양한 곳에서 사용될 수 있다.

function restOfPath(path: string) {
    const a = path.split('/').slice(1).join('/')
}

위의 코드를 살펴보면 처음에 string으로 들어왔던 path가 각각의 메서드를 거치면서 string[] -> string[] -> string으로 바뀌는 과정을 볼 수 있는데, 이때 각각의 체인 메서드에서 어떠한 형태로 타입이 변화하는지를 세부적으로 볼 수 있다. 이는 추가적인 디버깅을 거치지 않더라도 메서드를 통한 값의 변화가 어떻게 이루어지는지를 충분히 유추하는 좋은 도구가 된다.

null 배제하기

function myFunction(msg: string | null) {
    if(msg) {
        console.log(msg)
    }
}

위 코드처럼 string | null로 지정된 타입에서 if구문을 통해 값의 존재 여부를 따져 null을 배제할 수 있지만, null이 배제되지 않는 경우가 존재한다. 아래 코드를 살펴보자.

function getElement(elOrId: string | HTMLElement | null):HTMLElement {
    if(typeof elOrId === 'object') {
        return elOrId // Type 'HTMLElement | null' is not assignable to type 'HTMLElement'.
    } else if(elOrId === null) {
        return document.body
    } else {
        const el = document.getElementById(elOrId)
        return el // Type 'HTMLElement | null' is not assignable to type 'HTMLElement'.
    }
}

위 코드에서 오류가 나는 부분은 두 곳인데, 하나씩 알아보자.
처음 오류가 나는 곳에서 코드의 의도는 if문을 통해 object 타입인 HTMLElement를 걸러내는 것이었다. 그러나 null 또한 object로 간주되기 때문에...(typeof null = object) 해당 if문에서 정제가 불가능했다.

따라서 이러한 경우 null 체커를 추가하여 먼저 걸러내야한다.
그러나 이렇게 정제가 완료된 elOrId를 이용해 document.getElementById로 다시 객체를 가져오게 된다면 그 타입이 다시 HTMLElement | null로 돌아가게 된다. 이러한 경우 정제를 원한다면 다시 null 체커를 사용해야할 수도 있다.

lib.dom.d.ts에서 더 많은 타입을 탐색하다 보면 처음에는 타입 선언에 대해 이해하기 어려울지라도 서서히 TS가 무슨 일을 하고, 라이브러리가 어떻게 모델링되는지, 어떻게 오류를 찾아내는지 알 수 있는 훌륭한 수단이 될 것입니다.

TS를 이용한 코드 작성에 있어 타입을 확인하는 것은 매우 중요하다. 이에 익숙해진다면 분명 더 견고한 코드를 작성할 수 있을 것이다.

타입을 값들의 집합이라고 생각하기

number타입을 생각해보자. 수없이 많은 숫자들의 집합이라고 생각할 수 있지 않은가? string의 경우에도 모든 문자열들에 대한 집합이라고 여길 수 있다. nullundefined의 경우에도 strictNullCheck와 같은 옵션을 어떻게 설정하냐에 따라서 모든 타입에 포함될 수도, 모든 타입에서 제외될 수도 있다.

이처럼 타입을 값들의 집합이라고 생각한다면 그 구조를 파악하기 더 수월해질 것이다.

책에서는 아무것도 없는 공집합을 TS 내애서 가장 작은 집합으로 정의하고 있으며 이는 TS에서 never타입으로 나타내어진다. 아래 코드를 살펴보자.

const x: never = 12 // Type 'number' is not assignable to type 'never'

never 타입으로 선언된 변수의 경우에는 그 어떤 값도 할당할 수 없다. never는 보통 도달하면 안되는 곳에 사용하여 자연스럽게 에러 메세지를 보내도록 할 때 사용한다.

그 다음으로 작은 집합은 한 가지 값만 포함하는 집합이다. 다음 예시를 보자.

type a = 'A'
type b = 'B'
type twelve = 12

위와 같은 예시는 TS에서 unit타입이라고 불리는 리터럴 타입이다.

추가적으로 변수의 선언 방식에 따라 타입이 달라지는 경우가 존재한다. 다음 코드를 살펴보자.

let twelve = 12 // let twelve: number
const eleven = 11 // const eleven: 11

위의 코드처럼 let으로 선언된 변수는 변경이 가능하므로 그 타입을 숫자들의 집합인 number로 추론하였다. 그러나 const로 선언된 변수는 변경이 불가하믐로 그 타입을 할당된 숫자 그대로 지정하였다. 즉 12만을 값으로 가지는 유닛 타입으로 추론된 것이다.

그 다음으로 작은 집합은 2개 혹은 3개 이상의 값을 묶어 지정한 union 타입이다. 다음 코드를 살펴보자.

type AB = "A" | "B"
type AB12 = "A" | "B" | 12
type AB12_2 = AB | 12
let a: AB12_2 = 12 as AB12

위 코드처럼 여러 개의 값을 묶어 지정한 타입이 union타입이고, 이는 저번 글에서 공부한 구조적 타이핑처럼 타입들의 대소 관계를 따져 적용할 수 있다. 구조적 타이핑에서도 그렇고 타입을 집합이라고 생각하는 것이 그 관계를 따지는 데 훨씬 도움이 되는 것 같다.

TS에서는 다음과 같은 일도 일어날 수 있다.

type AB = "A" | "B"
type AB12 = "A" | "B" | 12
const ab: AB = Math.random() < 0.5 ? "A" : "B"
const ab12: AB12 = ab // AB12가 AB를 포함하고 있으므로 가능하다.
declare let abc: AB
const back: AB = abc

위 코드의 마지막 두 줄을 살펴보자...
declare 키워드를 통해 abc값의 타입이 AB로 지정되어있음을 알리고, 그 값을 할당해주지는 않았다. 즉 아직 값이 undefined인 것이다.

그리고 다음 줄을 보면 undefinedabc를 다른 const 변수에 할당해주고 있다... 타입이 일치하는 것과 별개로 const로 선언된 변수에 값이 담기지 않을 수 있다...조금 특이한 특징인 것 같으니 따로 알아두면 좋을 것 같다.

타입을 합집합으로 표현할 수 있다. 다음 코드를 살펴보자.

interface identified {
    id: string
}
interface Person {
    name: string
}

interface Lifespan {
    birth: Date
    death?: Date
}
type PersonSpan = Person & Lifespan

const a: PersonSpan = {
    name: 'a',
    birth: new Date(),
    death: new Date(),
    abc: 12 // Type '{ name: string; birth: Date; death: Date; abc: number; }' is not assignable to type 'PersonSpan'.
}

위 코드의 PersonSpan이라는 타입은 Person 타입과 Lifespan타입의 합집합으로 나타내졌다. 그래서 두 타입 어디에도 속하지 않는 number타입을 가진 abc는 에러를 발생시킨다.

보통 &&연산자는 교집합을 생각하게 하므로 두 타입에 모두 속한 속성들만 지정한 것이라고 생각하기 쉬운데, &연산자는 타입들의 합집합임을 기억하자!

반대로, | 연산자를 사용하면 여러 타입들의 교집합인 속성들만을 지정하는 타입을 정의하게 된다. 다음 코드를 보자.

interface identified {
    id: string
}
interface Person {
    name: string
}

interface Lifespan {
    birth: Date
    death?: Date
}
type K = keyof (Person | Lifespan)
const k: K = a // type K = never

위 코드에서 타입 Knever로 선언되는데, 이는 PersonLifespan에 겹치는 속성이 없기 때문이다. 그래서 다음과 같이 Personbirth와 같은 속성을 추가해주면 Kbirth가 된다.

interface identified {
    id: string
}
interface Person {
    name: string
    birth: Date
}

interface Lifespan {
    birth: Date
    death?: Date
}
type K = keyof (Person | Lifespan)
const k: K = 'birth'

다만 한 가지 궁금한 것이 있다. 위의 코드에서는 keyof 키워드를 통해 타입 내의 키값들만 가져왔다. 그렇다면 keyof를 사용하지 않는다면?

이 경우에 &연산자를 사용한다면 이는 PersonLifespan의 속성을 모두 가져야 만족하는 타입이 생성되고, |연산자를 사용한다면 둘 중 적어도 하나의 타입 조건만을 만족하면 되는 타입이 생성된다. 아래 예시를 통해 이해해보자.

interface identified {
    id: string
}
interface Person {
    name: string
    birth: Date
}

interface Lifespan {
    birth: Date
    death?: Date
}
type J = Person & Lifespan
type K = Person | Lifespan

const a: J = { // Type '{ birth: Date; death: Date; }' is not assignable to type 'J'.
    birth: new Date(),
    death: new Date(),
}

const b: K = {
    birth: new Date(),
    death: new Date(),
}

결과적으로 보면 다음 식이 성립한다는 것이다!

/**
 * keyof (A| B) = (keyof A) & (keyof B)
 * keyof (A & B) = (keyof A) | (keyof B)
 */
// 드 모르간 아조씨...?

집합을 다시 공부하는 것 같은 느낌이다...

사실 위처럼 연산자를 사용하기보다는 실제로는 extend 키워드를 사용하는 것이 더 일반적이다.

interface Person {
    name: string
}

interface PersonSpan extends Person {
    birth: Date
    death?: Date
}

이럴 때 사용되는 '서브타입'이라는 개념은 '특정 타입이 다른 타입의 부분집합이다'라는 의미이다.

이는 아래처럼 코드의 확장에 유용하게 사용된다.

interface Vector1D {
    x: number
}
interface Vector2D extends Vector1D {
  // x: number
    y: number
}
interface Vector3D extends Vector2D{
  // x: number
  // y: number
    z: number
}

위 코드처럼 차원을 확장하듯이 코드를 확장하고 기능을 추가해나갈 수 있다.

마치 데코레이터 패턴을 보는 듯 하다!

위의 내용에 대한 전반적인 이해가 완료되었다면 다음 코드를 이해해보자.

interface Point {
    x: number
    y: number
}
type PointKeys = keyof Point // type is "x", "y"

function sortBy<K extends keyof T, T>(vals: T[], key: K): T[] {
    vals.sort((a, b) => (a[key] === b[key] ? 0 : a[key] < b[key] ? -1 : +1))
    return vals
}
const pts: Point[] = [
    { x: 1, y: 1 },
    { x: 2, y: 0 }
]
sortBy(pts, 'y')
sortBy(pts, 'x')
sortBy(pts, Math.random() < 0.5 ? 'x' : 'y')
sortBy(pts, 'z') // Argument of type '"z"' is not assignable to parameter of type 'keyof Point'.

제네릭을 잘 이해한다면 어렵지 않을 것이다. T라는 제네릭과 이 T의 키들을 확장한 속성들을 유니온 타입으로 가지는 K 제네릭이 사용되는 예제이다.


궁금한 점...

제네릭에 extends를 사용하여 타입을 확장한 경우라면 기존의 타입에 추가적인 속성을 붙이고 사용할 수 있어야 하는 것이 아닌가...?

-> 여러 시도를 거쳐봤는데 아마도 제네릭을 사용할 때 <K: keyof T>와 같은 식으로 선언하는 것이 불가능하다. 그래서 그 대신에 extends 키워드를 사용하는 것 같다. 그럼 결국에 제네릭에 한정해서는 extends가 확장의 의미가 아닌 타입 지정의 의미로 봐도 괜찮다는 건가?

다음은 생각하는데 쓰인 추가적인 예제이다.

// Model 클래스
class Model<T> {
  
    constructor(public _data:T[] = []) {}
    
    add(item:T):void {
      this._data.push(item);
    }
  
  }
  
  // Model 초기화 팩토리 함수
  function initializeModel<T extends Model<U>, U>(C: new () => T, items:U[]):T {
    const c = new C();
    items.forEach(item => c.add(item));
    return c;
  }
  
  // 사용 예시
  initializeModel<Model<string>, string>(Model, ['타입', '변수', '상속']);
  const a: Model<string> = { _data: [ 'a', 'b' ] , add(item) {} }

위 코드를 짜면서 하나 파악한 것이 있는데, 클래스를 정의할 때 private으로 지정된 생성자는 이후 해당 클래스를 타입으로 사용하게 되었을 때 에러를 발생시킨다. 클래스를 타입으로 사용하고자 하는 의도가 있다면 private 생성자는 없어야 한다!


타입에 튜플이 사용되는 경우

타입으로 튜플 자료형이 오는 경우에는 조금 더 엄격한 조건이 적용된다. 다음 코드를 살펴보자.

const list: number[] = [1, 2]
const tuple: [number, number] = list // Target requires 2 element(s) but source may have fewer.

위 코드에서는 tuple 변수에 두 개보다 적은 수의 number가 올 수 있으므로 list로 할당이 불가하다는 에러를 발생하고 있다. 이러한 경우 list의 타입을 [number, number, number]와 같이 설정하는 경우에는 list에 3개 요소가 있지만 tuple에서는 2개만 허용한다는 에러가 표시된다. 따라서 위 코드의 에러를 완전히 없애기 위해서는 listtuple과 같은 [number, number]타입을 지정해주어야만 한다.

결국 [number, number]number[]의 부분집합이라고 생각할 수 있는 것이다. 그래서 아래 코드는 정상 작동한다.

const tuple: [number, number] = [1, 2]
const list: number[] = tuple

이처럼 튜플을 사용한다면 좀 더 엄격한 타입 체킹을 할 수 있다.

Exclude 메서드

Exclude 메서드를 사용한다면 타입 배제 및 정제를 더 수월하게 할 수 있다.

type T = Exclude<string | Date, string | number>
type NonZeroNums = Exclude<number, 0> // Type is still just number
const a: NonZeroNums = 0 // no err

Exclude 메서드를 사용하면 제네릭에 입력된 두 타입으로 (왼쪽 타입) - (오른쪽 타입)의 차집합 연산을 수행한 결과를 반환한다.
그러나 코드의 두 번째 줄을 보면 number 중 0을 제외한 number타입을 얻고 싶어 Exclude메서드를 사용하였음에도 그 효과를 보지 못했다.
이처럼 Exclude메서드를 사용하더라도 특정 정수만 제외하거나 float값만 제외하거나 할 수 없음을 알 수 있다.

즉, 유니온 타입끼리의 차집합을 구하는 등의 경우에만 유용할 것으로 생각된다.


아고...짱짱 길었다.
그래도 많이 배우는 것 같아 다행이다...!

profile
https://nohv.site

0개의 댓글