본문 바로가기

HTML ⁄ CSS ⁄ JS

[이펙티브 타입스크립트] 5장 any 다루기

이펙티브 타입스크립트 정주행 스터디 5주차

 

다른 프로그래밍 언어들과는 달리, 타입스크립트의 타입시스템은 점진적으로(gradually) 프로그램의 일부분에만 적용할 수 있다. 부분적이고 점진적인 마이그레이션을 위해 any 타입은 꼭 필요하다. 

any는 그만큼 강력하기 때문에 남용하게 될 여지가 있다. 이번 장에서는 any 타입을 어떻게 사용해야 현명하게 사용하는 것인지 알아보자. 

 

38 좁은 범위에서만 쓰기

any를 사용하더라도 가능한 한 좁은 범위 내에서만 사용하면, 의도치 않게 타입 안정성을 잃는 일을 최소화할 수 있다. 

const config1: Config {
  a: 1,
  b: 2,
  c: {
    key: value // 에러, Foo 타입에 foo 프로퍼티가 필요하지만 Bar 타입에는 없습니다.
  }
}

const config2: Config {
  a: 1,
  b: 2,
  c: {
    key: value 
  } as any    // 정상, a,b 프로퍼티 마저 타입체크 안됨
}

const config3: Config {
  a: 1,
  b: 2,
  c: {
    key: value as any  // 정상, a,b 프로퍼티 마저 타입체크 가능
  }
}

 

강제로 타입 오류를 제거하고 싶다면 any 타입 대신 @ts-ignore를 사용하는 방법도 있다. 타입 자체에 문제가 있는 경우라면 any나 @ts-ignore를 사용하기보다는 근본적으로 문제를 해결해서 또 다른 에러가 발생하는 것을 막는 것이 바람직하다.

function f1() {
  const x: any = expressionReturningFoo();

  processBar(x); // 통과
  return x; // any 타입으로 반환. any 타입이 전역적으로 퍼지게 됨.
}

function f2() {
  const x = expressionReturningFoo();

  processBar(x as any); // 통과
  return x; // Foo 타입으로 반환
}

function f2() {
  const x = expressionReturningFoo();

  // @ts-ignore
  processBar(x); // 통과
  return x; // Foo 타입으로 반환
}

 

함수의 경우, 리턴 타입을 추론할 수 있는 경우에도 리턴 타입을 명시적으로 지정해주는 것이 좋다. 리턴 타입을 any로 하는 것은 지양해야 하는 패턴이다.

 

39 구체적으로 변형해서 쓰기

단순 any 타입은 number, string, array, object, regexp, function, class, DOM element, null, undefined 이 모든 타입을 포함한다. 만약 any 타입을 지정하게 되었다면, 정말로 any 타입을 지정할 만큼 '모든 타입'을 허용해야하는 상황인지 다시 한번 생각해보자. 많은 경우에 단순 any 타입보다는 더 구체적으로 지정할 수 있다.

어떤 형태든 배열이기만 하면 된다면 any 대신 any[]를 쓸 수 있다. 어떤 형태든 객체이기만 하면 된다면 any 대신 { [key: string]: any } 를 쓸 수 있다. object 타입을 대신 쓸 수도 있지만, o[key]와 같이 접근하려고 하면 에러가 발생한다. 

function getLength(array: any) {  // any[] 로 구체화할 수 있다.
  return array.length;
}

function has5LetterKey(o: any){  // { [key: string]: any } 로 구체화할 수 있다.
  for (const key in o) {
    if (key.length === 5) return true;
  }
  return false;
}

 

아래 예제의 Fucntion2 는 Function 타입과 동일하다.

type Function0 = () => any; // 매개변수 0개만 가능
type Function1 = (arg: any) => any; // 매개변수 1개만 가능
type Function2 = (...args: any[]) => any; // 매개변수 몇 개든 가능.

 

 

40 함수 안으로 타입 단언문 감추기

모든 예외상황을 고려하면서 딱 들어맞는 타입을 구현할 필요는 없다. 함수 내부에서 타입 단언을 적당히 사용하되, 함수 외부로 드러나는 타입을 명확하게 하는 정도로 작성하는 것이 더 좋다. 예외상황일 경우, 함수 안에서 타입 단언으로 처리하면서 이를 외부로 드러내지 않는 방법을 사용할 수 있다. 

declare function cacheLast<T extends Function>(fn: T): T;

declare function shallowEqual(a: any, b: any): boolean;

function cacheLast<T extends Function>(fn: T): T {
  let lastArgs: any[] | null = null; // 마지막 args를 기억할 변수
  let lastResult: any; // 마지막 실행결과를 기억할 변수

  return function returnFn(...args: any[]) {
    if (!lastArgs || !shallowEqual(lastArgs, args)) {
      lastResult = fn(...args);
      lastArgs = args;
    }
    return lastResult;
  }
  // 에러, (..args: any[]) => any 타입인 returnFn을 T 타입에 할당할 수 없습니다.
  // as unknown as T; 로 해결할 수 있음.
}

위 예제에서 타입스크립트는 원본함수(fn)와 리턴하는 함수 간의 관계를 알지못해 타입 에러가 발생한다. 여기서 리턴하는 함수는 항상 원본함수(fn)와 같은 매개변수를 받아 같은 타입을 리턴하므로 함수 내부에서 타입 단언문을 사용해도 괜찮다.

 

declare function shalloEqual(a: any, b: any): boolean;

function shallowObjectEqaul<T extends object>(a: T, b: T): boolean {
  if (Object.keys(a).length !== Object.keys(b).length) {
    return false;
  }

  for (const [aKey, aValue] of Object.entries(a)) {
    if (!(aKey in b) || b[aKey] !== aValue) {
                       // ~~~ '{}' 타입에 인덱스 시그니처가 없으므로 요소에 암시적 any 형식이 있습니다.
                       // (b as any)[aKey] 로 해결할 수 있음.
      return false;
    }
  }

  return true;
}

위 예제에서 aKey가 b 에 있는 것을 확인했으므로 b as any 라는 타입 단언문은 안전하다.

 

 

41 진화하는 any 이해하기

보통 변수가 선언된 이후에는 타입이 정제될 수는 있어도 확장될 수는 없다. string 타입인데 그 이후로 string[] 타입이 될 수는 없다. 그러나 any의 경우에는 any[] 에서 string[]으로 변화하는 '진화(evolving)'이 가능하다. 이는 any 타입만의 특성이다.

const temp = []; // 암시적 any[];

result.push('kim'); // string[];
result.push(365); // (string | number)[];

 

암시적 any 타입인 변수를 아무런 할당 없이 사용하려고 하면, 타입체커가 에러를 발생시킨다. 이러한 해결책으로 선언 이후 할당 등을 통해 any 타입에서 다른 타입으로 진화시켜 해결할 수 있다. 이는 안전한 타이핑 방식은 아니지만, any와 관련된 타입스크립트의 타입 추론이 어떻게 동작하는지 이해하기 위해서 알아둘 필요가 있다.

any의 진화에는 다음과 같은 특징이 있다.

1. tsconfig.json에서 noImplicitAny 옵션이 켜져 있을 때 발생한다.
2. 암시적 any 타입에 어떤 값을 직접적으로 할당하거나 배열에 요소를 넣은 후에 발생한다. 
3. 암시적 any 타입에 함수호출 등을 통해 간접적으로 조작할 경우에는 발생하지 않는다.

2번의 특징에 대해 부연하자면, 할당이 일어나는 지점에서는 여전히 any, any[] 등으로 타입이 표시되고, 그 이후에 진화한 타입을 관찰할 수 있게 된다는 것을 기억해두자.

3번의 특징은 다음의 코드를 보면 더욱 쉽게 이해할 수 있다. forEach 구문에 인자로 넘긴 콜백 함수 안에서 out을 조작하더라도 out 의 any 타입은 진화하지 않는다.

functin makeSquares(start: number, limit: number) {
  const out = [];
        // 'out' 변수는 일부 위치에서 암시적으로 any[] 타입 입니다.

  range(start, limit).forEach(i => out.push(i ** 2)); // 함수 호출은 out의 타입에 영향을 미치지 않음.
  return out;
         // 'out'은 암시적으로 any[] 타입이 포함됩니다.
}

 

 

42 모르는 타입의 값에는 unknown 쓰기

변수나 함수의 리턴값의 타입을 미리 알기 어려운 경우 any 대신에 사용하면 좋은 unknown 타입에 대해 알아보자.

타입스크립트에서 any 타입은 거의 무법자와 같이 강력한 타입이다. 집합관점에서 타입을 생각해볼 때, 다른 타입의 superset 이면서 동시에 subset일 수는 없다. 그러나 any 타입은 모든 타입의 superset 이면서 모든 타입의 subset으로 동작한다. 즉, any 타입에 모든 타입을 할당할 수 있는 동시에 never를 제외한 모든 타입에 any 타입을 할당할 수 있다.

반면, unknown 타입에는 모든 타입을 할당할 수 있지만, 오직 unknown과 any 타입에만 unknown 타입을 할당할 수 있다.

< any 타입에 할당할 경우 > < unknown 타입에 할당할 경우 >
any = number (O)

any = number[] (O)

any = never (O)
unknown = number (O)

unknown = number[] (O)

unknown = never (O)
< any 타입을 할당할 경우 > < unknown 타입을 할당할 경우 >
number = any (O)

number[] = any (O)

never = any (X)


number = unknown (X)

number[] = unknown (X)

never = unknown (X)

any = unknown (O)

 

unknown 타입을 그대로 사용하려고 하면 타입체커가 에러를 발생시키기 때문에, 사용자가 적절한 타입으로 변환하도록 자연스럽게 유도한다. unknown 타입 에러는 타입 단언을 통해 타입을 지정하거나, instanceof를 체크해서 원하는 타입으로 변환하여 해결할 수 있다.

 

unknown 은 2018년 타입스크립트 3.0 부터 추가된 타입이다. (현재 버전은 4.5)

그 전에 사용되었던 유사 unknown 타입에는 {} 타입과 object 타입이 있다. {} 타입은 null, undefined 이외의 모든 타입을, object 타입은 객체, 배열 등 자바스크립트의 참조 타입(원시형이 아닌 타입)을 모두 포함한다.

 

 

43 몽키패치보다는 안전한 타입 쓰기

몽키패치(Monkey Patch)는 런타임 동안에 동적으로 객체에 메서드나 프로퍼티를 추가하는 패턴을 일컫는다. 원래는 예고없이 깜짝스럽게 진행된다는 뜻의 게릴라 패치(Guerrilla Patch)가 비슷한 발음의 고릴라 패치(Gorilla Patch)라고 불리던 중, 비개발자인 CEO에게 좀 위협적으로 들리기 때문에 고릴라 보다는 귀여운 원숭이로 바꿔 부르게 되었다는 유래가 있다. (출처)

몽키패치는 안티패턴으로 지양해야하지만 어쩔 수 없이 사용해야하는 경우가 발생할 수 있다. 그런데 타입스크립트에서 몽키패치는 타입체커가 임의로 추가한 속성에 대한 정보를 갖고있지 않다는 점에서 문제가 생긴다.

document.monkey = 'Magic';
        // Document 타입에 'monkey' 프로퍼티가 없습니다.

 

이는 interface의 보강(augmentation) 기능을 사용해서 해결할 수 있다.

interface Document {
  monkey: string; // 기존의 Document 인터페이스에 monkey 속성이 추가됨.
}

 

보강과 같이 전역적으로 적용되는 것을 피하고 싶다면 extends 키워드로 확장해서 사용하는 방법이 있다.

interface MonkeyDocument extends Document {
  monkey: string; 
}

(document as MonkeyDocument).monkey = 'Magic';

 

 

 

44 타입 커버리지를 추적하기

타입 커버리지를 추적함으로써, 코드 퀄리티를 꾸준하게 점검할 수 있다. 타입 커버리지 비율이란,  'any타입이 아닌 식별자의 수'를 '전체 식별자의 수' 로 나눈 수치이다. 비율이 높을수록 좋다고 할 수 있다.

타입 커버리지는 type-coverage 패키지를 npx 로 실행해서 간단히 확인할 수 있다.

$ npx type-coverage

type-coverage 실행 결과