EffectiveTypeScript: any와 @type

젼이·2024년 3월 21일

41. any의 진화를 이해하기

  • any 타입의 진화는 noImplicitAny(any라는 타입이 의도치않게 발생할 경우 에러를 띄워주는 설정)가 설정된 상태에서 변수의 타입이 암시적 any인 경우에만 일어난다.
// 배열 예시
function range(start: number, limit: number) {
  const out = []; // 타입이 any[]
  for (let i = start; i < limit; i++) {
    out.push(i); // out의 타입이 any[]
  }
  return out; // 타입이 number[]
}

// 단순값 예시
let val; // 타입이 any
if (Math.random()<0.5>) {
  val = /hello/;
  val; // 타입이 RegExp
} else {
  val = 12;
  val; // 타입이 number
}
val; // 타입이 number | RegExp
  • 타입의 진화는 타입 좁히기와 다르다. 배열에 다양한 타입의 요소를 넣으면 배열의 타입이 확장되며 진화한다.
const result = []; // 타입이 any[]
result.push("a");
result; // 타입이 string[]
result.push(1);
result; // 타입이 (string | number)[]
  • any타입의 진화는 암시적 any 타입에 어떤 값을 할당할 때만 발생한다. 그리고 어떤 변수가 암시적 any 상태일 때 값을 읽으려고 하면 오류가 발생한다.
function range(start: number, limit: number) {
  const out = [];
  //  ~~~ 'out' 변수는 형식을 확인할 수 없는 경우 일부 위치에서 암시적으로 'any[]' 형식입니다.
  if (start === limit) {
    return out;
    //  ~~~ 'out' 변수에는 암시적으로 'any[]' 형식이 포함됩니다.
  }
  for (let i = start; i < limit; i++) {
    out.push(i);
  }
  return out;
}
  • any를 진화시키는 방식보다 명시적 타입 구문을 사용하는 것이 안전한 타입을 유지하는 방법이다.

42. 모르는 타입의 값에는 any 대신 unknown을 사용하기

unknown은 any 대신 사용할 수 있는 안전한 타입이다.

  • 함수의 반환값과 관련된 unknown
// Bad
interface Book {
  name: string;
  author: string;
}
const book: Book = parseYAML(`
    name: Wuthering Heights
    author: Emily Bronte
`);

alert(book.title); // 오류 없음, 런타임에 "undefined" 경고
book("read"); // 오류 없음, 런타임에 "TypeError: book은 함수가 아닙니다" 예외 발생

// Good
function safeParseYAML(yaml: string): unknown {
  return parseYAML(yaml);
}
const book = safeParseYAML(`
    name: The Tenat of Wildfell Hall
    author: Anne Bronte
`);
alert(book.title);
//  ~~~ 개체가 'unknown' 형식입니다.
book("read");
//  ~~~ 개체가 'unknown' 형식입니다.

any가 위험한 이유

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

unknown 타입은 any의 첫 번째 속성을 만족하지만, 두 번째 속성은 만족하지 않습니다.
반면 never 타입은 unknown 타입과 정반대입니다.

  • 변수 선언과 관련된 unknown
// 어떠한 값이 있지만 그 타입을 모르는 경우
interface Feature {
  id?: string | number;
  geometry: Geometry;
  properties: unknown;
}

// instanceof를 체크한 후 unknown에서 원하는 타입으로 변환
function processValue(val: unknown) {
  if (val instanceof Date) {
    val; // 타입이 Date
  }
}
  • 단언문과 관련된 unknown
declare const foo: Foo;

// Bad
let barAny = foo as any as Bar;

// Good
let barUnk = foo as unknown as Bar;
  • {}, object, unknown 차이점
    • {} 타입은 null과 undefined를 제외한 모든 값을 포함한다.
    • object 타입은 모든 비기본형(non-primitive) 타입으로 이루어진다.
      여기에는 true 또는 12 또는 "foo"가 포함되지 않지만 객체와 배열은
      포함된다.
    • unknown 타입이 도입되기 전에는 {}가 더 일반적으로 사용되었지만,
      최근에는 {}를 사용하는 경우가 꽤 드물다.
      정말로 null과 undefined가 불가능하다고 판단되는 경우만 unknown
      대신 {}를 사용하면 된다.

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. 타입 커버리지를 추적하여 타입 안정성 유지하기

noImplicitAny를 설정하고 모든 암시적 any 대신 명시적 타입 구문을 추가해도 any 타입이 여전히 프로그램 내에 존재할 수 있다.

  1. 명시적 any 타입
  • 특히 any[]{ [key: string]: any } 같은 타입은 인덱스를 생성하면 단순 any사 되고 코드 전반에 영향을 미친다.
  1. 서드파티 타입 선언
  • 이 경우에는 @types 선언 파일로부터 any 타입이 전파되기 때문에 특별히 조심해야 한다.
    noImplicitAny를 설정하고 절대 any를 사용하지 않았다 하더라도 여전히 any 타입 코드 전반에 영향을 미친다.

any 타입은 타입 안정성과 생산성에 부정적 영향을 미칠 수 있으므로 프로젝트에서 any의 개수를 추적하는 것이 좋다.

  • npm의 type-coverage 패키지를 활용하여 any를 추적할 수 있다.
  • any가 아니거나 any티입이 추가된다면 백분율이 감소하게 된다.
  • npx type-coverage --detail 명령어를 사용하면 any 타입이 있는 곳을 모두 출력해준다.
  • 이것을 조사해 보면 미처 발견하지 못한 any를 찾을 수 있다.
  • noImplicitAny를 설정하고 모든 암시적 any 대신 명시적 타입 구문을 추가해도 any 타입과 관련된 문제들로부터 안전하다고 할 수 없다.

45. devDependencies에 typescript와 @types 추가하기

npm의 3가지 종류의 의존성

  • dependencies: 프로젝트 런타임시 실행되어야할것들 여기에 포함
  • devDependencies: 런타임에 필요 없는 라이브러리들 포함
  • peerDependencies: 의존성을 직접 관리하지 않는 라이브러리들

ts와 관련 라이브러리들은 일반적으로 devDependencies에 속한다.

TS 프로젝트에서 공통적으로 고려해야 할 의존성 두 가지

타입스크립트 자체 의존성 고려하기

  • 타입스크립트를 시스템 레벨로 설치하는 것을 추천하지 않음
    • 팀원들 모두가 항상 동일한 버전 사용한다는 보장이 없음
    • 프로젝트를 셋업할 때 별도의 단계 추가됨
  • npm install을 실행할 때 팀원들 모두 항상 정확한 버전의 타입스크립트를 설치 할 수 있다.
  • 타입스크립트를 시스템 레벨로 설치하기보다는 devDependencies에 넣는 것이 좋다.

타입 의존성(@types) 고려하기

  • @types 의존성은 devDependencies에 있어야 한다.
  • 런타임에 @types가 필요한 경우라면 별도의 작업이 필요하다.
$ npm install react
$ npm install --save-dev @types/react

46. 타입 선언과 관련된 세 가지 버전 이해하기

타입스크립트에서 의존성 관리는 더 복잡하다.
타입스크립트를 사용하면 다음 세 가지 사항을 추가로 고려해야 하기 때문이다.

  • 라이브러리의 버전
  • 타입 선언(@types)의 버전
  • 타입스크립트의 버전

세 가지 버전 중 하나라도 맞지 않으면 오류가 발생할 수 있다. 이렇게 발생한 오류의 원인을 파악하고 고치기 위해서는 타입스크립트 라이브러리 관리의 복잡한 메커니즘을 모두 이해해야 한다.

타입스크립트에서 일반적으로 의존성을 사용하는 방식은 다음과 같다.

$ npm install react
+ react@16.8.6

$ npm install --save-dev @types/react
+ @types/react@16.8.19

실제 라이브러리와 타입 정보의 버전이 별도로 관리되는 방식은 다음 네 가지 문제점이 있다.

  1. 라이브러리를 업데이트했지만 실수로 타입 선언은 업데이트 하지 않는 경우

    • 해결책: 타입 선언도 업데이트하여 라이브러리와 버전을 맞춘다.
  2. 라이브러리보다 타입 선언의 버전이 최신인 경우

    • 해결책: 라이브러리와 타입 선언의 버전이 맞도록 라이브러리 버전을 올리거나 타입 선언의 버전을 내린다.
  3. 프로젝트에서 사용하는 타입스크립트 버전보다 라이브러리에서 필요로 하는 타입스크립트 버전이 최신인 경우

    • 해결책: 프로젝트의 타입스크립트 버전을 올리거나, 라이브러리 타입 선언의 버전을 원래대로 내리거나, declare module 선언으로 라이브러리의 타입 정보를 없애 버린다.
  4. @types 의존성이 중복될 수 있다

node+modules/
	@types/
	foo/
		index.d.ts @1.2.3
	bar/
		index.d.ts
		node_modules/
			@types/
				foo/
					index.d.ts @2.3.4

만약 @types/foo와 @types/bar에 의존하는 경우를 가정해보자. 만약 @types/bar가 현재 프로젝트의 호환되지 않는 버전의 @types/foo에 의존한다면 npm은 중첩된 폴더에 별도로 해당 버전을 설치하여 문제를 해결하려고 한다.

런타임에 사용되는 모듈이라면 괜찮을 수 있지만, 전역 네임스페이스(name-space)에 있는 타입 선언 모듈이라면 대부분 문제가 발생한다.

  • 해결책: 보통 @types/foo를 없데이트 하거나 @types/bar를 업데이트해서 서로 버전이 호환되게 한다.

47. 공개 API에 등장하는 모든 타입을 익스포트하기

서드파티의 모듈에서 익스포트되지 않은 타입 정보가 필요한 경우

interface SecretName {
  first: string;
  last: string;
}

interface SecretSanta {
  name: SecretName;
  gift: string;
}

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

해당 라이브러리 사용자는 SecretName 또는 SecretSanta를 직접 임포트할 수 없고, getGift만 임포트 가능하다.

이때 익스포트 되지 않은 타입을 추출하는 한 가지 방법은 ParametersReturnType 제너릭 타입을 사용한다.

type MySanta = ReturnType<typeof getGift>; // SecretSanta
type MyName = ReturnType<typeof getGift>[0]; // SecretName

| 공개 메서드에 등장한 어떤 형태의 타입이든 익스포트 하자. 어차피 라이브러리 사용자가 추출할 수 있으므로, 익스포트하기 쉽게 만드는 것이 좋다.


48. API 주석에 TSDoc 사용하기

사용자를 위한 문서라면 JSDoc 스타일의 주석으로 만드는 것이 좋다.

  • 대부분의 편집기는 함수가 호출되는 곳에서 함수에 붙어 있는 JSDoc 스타일의 주석을 툴팁으로 표현해 줌
  • 그러나 인라인(inline)주석은 편집기가 표시해 주지 않음
// inline 주석

/** JSDoc 주석 */

공개 API에 주석을 붙인다면 JSDoc 형태로 작성해야 한다.

  • JSDoc에는 @param@returns 같은 일반적 규칙을 사용할 수 있다.
  • @param@returns를 추가하면 함수를 호출하는 부분에서 각 매개변수와 관련된 설명을 보여준다.
/**
  * 인사말을 생성합니다.
  * @param name 인사할 사람의 이름
  * @param title 그 사람의 칭호
  * @returns 사람이 보기 좋은 형태의 인사말
*/
function greetFullTSDoc(name: string, title: string) {
  return `Hello ${title} ${name};
}

타입 정의에 TSDoc를 사용할 수 있다.

  • 아래 예시코드에서 Measurement 객체의 각 필드에 마우스를 올려 보면 필드별로 설명을 볼 수 있다.
  • TSDoc 주석은 마크다운 형식으로 꾸며지므로 굵은 글씨, 기울임, 글머리 기호 목록을 사용할 수 있다.
  • JSDoc에는 타입 정보를 명시하는 규칙(@param {string} name ...)이 있지만, 타입스크립트에서는 타입 정보가 코드에 있기 때문에 TSDoc에서는 타입 정보를 명시하면 안된다.
/** 특정 시간과 장소에서 수행된 측정 */
interface Measurement {
  /** 어디에서 측정되었나? */
  position: Vector3D;
  /** 언제 측정되었나? epoch에서부터 초 단위로 */
  time: number;
  /** 측정된 운동량 */
  momentum: Vector3D;
}

| 주석은 간단히 요점만 언급해야 한다.

profile
코드도 짜고, 근육도 짜고

0개의 댓글