본문으로 건너뛰기

Effective typescript

VanderKam, DanISBN 9788966265046

목차92개 항목

Chapter 1: Getting to Know TypeScript

TypeScript는 JavaScript로 컴파일되는 언어다. 실행되는 것은 TypeScript가 아니라 컴파일된 JavaScript이므로, TypeScript와 JavaScript의 관계를 정확히 이해하는 것이 숙련된 사용의 출발점이다.

Item 1: TypeScript와 JavaScript의 관계 이해하기

TypeScript는 JavaScript의 상위집합(Superset)

  • 문법적으로 유효한 모든 JavaScript 프로그램은 TypeScript 프로그램이기도 하다
  • 역은 성립하지 않음: TypeScript에는 JavaScript에 없는 타입 애너테이션 문법이 존재한다
  • .js 파일을 .ts로 이름만 바꾸어도 유효한 TypeScript가 된다
  • 이 점이 점진적 마이그레이션을 가능하게 하는 TypeScript의 핵심 강점이다
function greet(who: string) {
  console.log('Hello', who);
}
// : string 은 TypeScript 전용 문법 → Node.js 에서 직접 실행 불가

타입 애너테이션 없이도 오류 감지

let city = 'new york city';
console.log(city.toUppercase());
//               ~~~~~~~~~~~ Property 'toUppercase' does not exist on type 'string'.
//                           Did you mean 'toUpperCase'?
  • TypeScript는 city의 타입을 초기값에서 추론하여 오탈자를 잡아낸다
  • 타입 추론(Type Inference)은 TypeScript의 핵심 기능 중 하나다

타입 애너테이션이 더 정확한 오류를 만든다

interface State {
  name: string;
  capital: string; // 'a' 로 spelling
}

const states: State[] = [
  { name: 'Alabama', capitol: 'Montgomery' },
  //                 ~~~~~~~
  // Object literal may only specify known properties,
  // but 'capitol' does not exist in type 'State'.
  // Did you mean to write 'capital'?
];
  • 타입 정보 없이는 TypeScript가 “어느 쪽이 맞는 철자인지” 알 수 없어 잘못된 수정 제안을 할 수 있다
  • 의도를 명시(타입 선언)할수록 TypeScript가 더 정확한 오류를 잡는다

TypeScript 타입 시스템의 한계: 완전한 건전성(Soundness) 없음

const names = ['Alice', 'Bob'];
console.log(names.toUpperCase());
// 타입 오류는 없지만 런타임에 TypeError 발생!
  • TypeScript의 정적 타입과 런타임 실제 타입이 달라질 수 있다
  • TypeScript 타입 시스템은 의도적으로 완전 건전하지 않도록 설계되어 있다
  • JavaScript의 유연한 런타임 동작을 모델링하는 것이 목표이기 때문이다

핵심 정리

  • 모든 JavaScript는 TypeScript이지만, 모든 TypeScript가 JavaScript는 아니다
  • TypeScript는 런타임 예외를 일으킬 코드를 정적으로 탐지하려 한다
  • 타입 검사를 통과해도 런타임 오류가 발생할 수 있다
  • TypeScript는 일부 유효하지만 의심스러운 JavaScript 구문(인자 수 불일치 등)을 허용하지 않는다

Item 2: 사용 중인 TypeScript 옵션 파악하기

TypeScript 컴파일러 옵션이 다르면 동일한 코드도 완전히 다르게 동작한다.

tsconfig.json 사용 권장

{
  "compilerOptions": {
    "noImplicitAny": true,
    "strictNullChecks": true
  }
}
  • 커맨드라인 플래그(tsc --noImplicitAny)보다 tsconfig.json을 사용할 것
  • 팀원과 도구 모두 동일한 설정을 공유하기 위해 필요하다
  • tsc --init으로 기본 설정 파일을 생성할 수 있다

noImplicitAny: 암묵적 any 차단

// noImplicitAny: off 일 때 → 정상 통과 (any로 추론됨)
function add(a, b) {
  return a + b;
}
// 에디터에서 hover하면: function add(a: any, b: any): any

// noImplicitAny: on 일 때 → 오류 발생
function add(a, b) {
  //         ~    Parameter 'a' implicitly has an 'any' type
  //            ~ Parameter 'b' implicitly has an 'any' type
  return a + b;
}

// 수정 방법: 명시적 타입 선언
function add(a: number, b: number) {
  return a + b;
}
  • 신규 프로젝트는 반드시 noImplicitAny를 켜고 시작할 것
  • JavaScript → TypeScript 마이그레이션 중에만 임시로 끌 수 있다

strictNullChecks: null/undefined를 별도 타입으로 취급

// strictNullChecks: off 일 때
const x: number = null; // OK

// strictNullChecks: on 일 때
const x: number = null;
//    ~ Type 'null' is not assignable to type 'number'

// null을 허용하려면 명시
const x: number | null = null; // OK
  • null/undefined 관련 런타임 오류(“undefined is not an object”)를 방지하는 핵심 옵션이다
  • 신규 프로젝트에서 TypeScript를 써본 경험이 있다면 반드시 켜야 한다
  • noImplicitAny보다 나중에 켜도 된다

strict 모드와 추가 옵션들

  • strict를 켜면 noImplicitAny, strictNullChecks 외 여러 검사 옵션이 한번에 활성화된다
  • tsc --init으로 생성한 프로젝트는 기본적으로 strict 모드다
  • noUncheckedIndexedAccess: 배열/객체 인덱스 접근 결과를 T | undefined로 취급 (strict보다 엄격)
const tenses = ['past', 'present', 'future'];
tenses.toUpperCase();
// noUncheckedIndexedAccess 시: Object is possibly 'undefined'.

핵심 정리

  • TypeScript 컴파일러 옵션은 언어의 핵심 동작에 영향을 준다
  • tsconfig.json으로 설정을 관리하라
  • JavaScript 마이그레이션 중이 아니라면 noImplicitAny를 켜라
  • strictNullChecks로 null/undefined 관련 런타임 오류를 예방하라
  • 궁극적으로 strict를 목표로 해야 한다

Item 3: 코드 생성은 타입과 독립적임을 이해하기

tsc(TypeScript 컴파일러)는 두 가지 독립적인 일을 한다:

  1. TypeScript/최신 JavaScript → 구버전 JavaScript로 트랜스파일
  2. 타입 오류 검사

이 두 동작은 완전히 독립적이다. 타입이 런타임 코드에 영향을 줄 수 없다.

런타임에 TypeScript 타입을 체크할 수 없다

interface Square { width: number; }
interface Rectangle extends Square { height: number; }
type Shape = Square | Rectangle;

function calculateArea(shape: Shape) {
  if (shape instanceof Rectangle) {
    //                 ~~~~~~~~~ 'Rectangle' only refers to a type,
    //                           but is being used as a value here
    return shape.height * shape.width;
  }
}
  • interfacetype은 컴파일 시 **완전히 제거(erasable)**된다
  • instanceof는 런타임 연산자 → 타입이 아닌 값(생성자 함수)에만 사용 가능
해결책 1: 프로퍼티 존재 확인(속성 체크)
function calculateArea(shape: Shape) {
  if ('height' in shape) {
    return shape.width * shape.height;
    //     ^? (parameter) shape: Rectangle
  } else {
    return shape.width * shape.width;
  }
}
해결책 2: 태그드 유니온(Tagged/Discriminated Union)
interface Square {
  kind: 'square';
  width: number;
}
interface Rectangle {
  kind: 'rectangle';
  height: number;
  width: number;
}
type Shape = Square | Rectangle;

function calculateArea(shape: Shape) {
  if (shape.kind === 'rectangle') {
    return shape.width * shape.height;
    //     ^? (parameter) shape: Rectangle
  } else {
    return shape.width * shape.width;
    //     ^? (parameter) shape: Square
  }
}
  • kind 같은 태그 프로퍼티를 통해 런타임에도 타입 정보를 복원할 수 있다
  • 태그드 유니온(= 판별 유니온, Discriminated Union)은 TypeScript에서 매우 흔한 패턴이다
해결책 3: class 사용 (타입 + 값 동시 도입)
class Square {
  constructor(public width: number) {}
}
class Rectangle extends Square {
  constructor(width: number, public height: number) { super(width); }
}
type Shape = Square | Rectangle;

function calculateArea(shape: Shape) {
  if (shape instanceof Rectangle) {  // OK! class는 런타임에 값(생성자)으로 존재
    return shape.width * shape.height;
  } else {
    return shape.width * shape.width;
  }
}
  • class타입과 값을 동시에 도입한다
  • interface는 타입만 도입한다

타입 오류가 있어도 출력이 생성된다

$ cat test.ts
let x = 'hello';
x = 1234;

$ tsc test.ts
test.ts:2:1 - error TS2322: Type '1234' is not assignable to type 'string'

$ cat test.js
var x = 'hello';
x = 1234;
# 오류가 있어도 JS 파일이 생성됨!
  • TypeScript 오류는 C/Java의 컴파일 오류가 아니라 경고에 가깝다
  • 오류 시 출력을 막으려면 noEmitOnError 옵션을 사용하라
  • “TypeScript가 컴파일 안 된다”는 표현은 기술적으로 틀렸다; 정확한 표현은 **“타입 체크에 실패한다”**이다

타입 연산은 런타임 값에 영향을 주지 못한다

function asNumber(val: number | string): number {
  return val as number; // 타입 단언은 런타임에서 아무 변환도 안 함!
}

// 컴파일된 JS:
function asNumber(val) {
  return val; // 그냥 그대로 반환
}

// 실제로 변환하려면:
function asNumber(val: number | string): number {
  return Number(val); // 런타임 JavaScript 변환 필요
}

런타임 타입은 선언 타입과 다를 수 있다

interface LightApiResponse {
  lightSwitchValue: boolean;
}
async function setLight() {
  const result: LightApiResponse = await response.json();
  setLightSwitch(result.lightSwitchValue);
  // API가 실제로 string을 반환하면? 런타임에 예상치 못한 동작 발생!
}

TypeScript 타입 기반 함수 오버로딩은 불가 (단, 타입 레벨 오버로드는 가능)

// 구현 중복 오류 발생:
function add(a: number, b: number) { return a + b; } // ~~~ Duplicate
function add(a: string, b: string) { return a + b; } // ~~~ Duplicate

// 올바른 방법: 타입 시그니처는 여러 개, 구현은 하나
function add(a: number, b: number): number;
function add(a: string, b: string): string;
function add(a: any, b: any) {
  return a + b;
}
const three = add(1, 2);   // ^? const three: number
const twelve = add('1', '2'); // ^? const twelve: string

핵심 정리

  • TypeScript 타입은 런타임 동작에 영향을 줄 수 없다
  • 타입 오류가 있어도 코드(“컴파일”)가 생성된다
  • 런타임에 타입 정보가 필요하다면 태그드 유니온, 속성 체크 등을 사용하라
  • class는 타입과 런타임 값 모두를 도입한다
  • TypeScript 타입은 런타임 성능에 영향을 주지 않는다 (zero cost)

Item 4: 구조적 타이핑(Structural Typing)에 익숙해지기

JavaScript는 덕 타이핑(Duck Typing) 기반이다: 올바른 프로퍼티를 갖고 있으면 그 타입으로 사용할 수 있다. TypeScript는 이를 **구조적 타입 시스템(Structural Type System)**으로 모델링한다.

구조가 호환되면 할당 가능하다

interface Vector2D {
  x: number;
  y: number;
}

function calculateLength(v: Vector2D) {
  return Math.sqrt(v.x ** 2 + v.y ** 2);
}

interface NamedVector {
  name: string;
  x: number;
  y: number;
}

const v: NamedVector = { x: 3, y: 4, name: 'Pythagoras' };
calculateLength(v);  // OK! Vector2D와 구조가 호환됨
  • NamedVectorVector2D 사이에 명시적 관계 선언 없이도 호환된다

구조적 타이핑의 함정

interface Vector3D {
  x: number;
  y: number;
  z: number;
}

function normalize(v: Vector3D) {
  const length = calculateLength(v); // Vector2D를 기대하는 함수에 Vector3D 전달 → 오류 없음!
  return { x: v.x / length, y: v.y / length, z: v.z / length };
}

normalize({ x: 3, y: 4, z: 5 });
// 결과: { x: 0.6, y: 0.8, z: 1 } → 길이가 1.4로 잘못됨!
  • TypeScript 타입은 “열린(open)” 타입이다 — 선언한 프로퍼티 외에 추가 프로퍼티가 있어도 할당 가능
  • “봉인된(sealed/closed)” 타입은 TypeScript의 타입 시스템으로 표현 불가능

Object.keys 루프의 타입 오류

function calculateLengthL1(v: Vector3D) {
  let length = 0;
  for (const axis of Object.keys(v)) {
    const coord = v[axis];
    //            ~~~~~~~ Element implicitly has an 'any' type
    length += Math.abs(coord);
  }
  return length;
}
// v가 { x, y, z, address: '123 Broadway' } 같이 추가 프로퍼티를 가질 수 있으므로
// axis의 타입은 string이 될 수밖에 없고, v[axis]는 항상 number라고 보장할 수 없다

// 더 나은 구현:
function calculateLengthL1(v: Vector3D) {
  return Math.abs(v.x) + Math.abs(v.y) + Math.abs(v.z);
}

클래스도 구조적으로 비교된다

class SmallNumContainer {
  num: number;
  constructor(num: number) {
    if (num < 0 || num >= 10) throw new Error('Out of range');
    this.num = num;
  }
}

const b: SmallNumContainer = { num: 2024 };  // OK! (구조가 맞으면 할당 가능)
// 생성자의 유효성 검사 로직이 실행되지 않음!

구조적 타이핑의 장점: 테스트 용이성

interface DB {
  runQuery: (sql: string) => any[];
}

function getAuthors(database: DB): Author[] {
  const rows = database.runQuery('SELECT first, last FROM authors');
  return rows.map(row => ({ first: row, last: row }));
}

// 테스트 시 PostgresDB 전체를 모킹할 필요 없이 인라인 객체로 대체 가능:
test('getAuthors', () => {
  const authors = getAuthors({
    runQuery(sql: string) {
      return [['Toni', 'Morrison'], ['Maya', 'Angelou']];
    }
  });
  expect(authors).toEqual([
    { first: 'Toni', last: 'Morrison' },
    { first: 'Maya', last: 'Angelou' }
  ]);
});
  • PostgresDBimplements DB를 선언하지 않아도 구조가 맞으면 전달 가능하다
  • 인터페이스로 추상화하면 구체 구현(PostgresDB)에 대한 의존 없이 테스트 가능하다

핵심 정리

  • TypeScript의 타입은 “열려(open)” 있다: 인터페이스에 명시된 것 이상의 프로퍼티를 가질 수 있다
  • 클래스도 구조적 타이핑을 따른다 — 예상한 클래스 인스턴스가 아닐 수 있다
  • 구조적 타이핑을 활용하면 단위 테스트 작성이 훨씬 쉬워진다

Item 5: any 타입의 사용 제한하기

TypeScript의 타입 시스템은 **점진적(gradual)**이고 **선택적(optional)**이다. any는 이를 가능하게 하는 핵심이지만, 그 위험성을 반드시 알고 사용해야 한다.

any의 위험성 1: 타입 안전성 없음

let ageInYears: number;
ageInYears = '12' as any; // 타입 체커가 통과시킴
ageInYears += 1;
// 런타임: "121" (number가 아닌 string!)

any의 위험성 2: 함수 계약 파기

function calculateAge(birthDate: Date): number {
  // ...
}

let birthDate: any = '1990-01-19'; // string인데 any로 선언
calculateAge(birthDate); // OK → 하지만 런타임 오류 가능

any의 위험성 3: 언어 서비스 비활성화

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

const formatName = (p: Person) => `${p.first} ${p.last}`;
//   → p.first, p.last에 대해 자동완성, 리네임 등 언어 서비스 완전 지원

const formatNameAny = (p: any) => `${p.first} ${p.last}`;
//   → 자동완성 없음, 리네임해도 반영 안 됨, 타입 체커 도움 없음
  • any는 자동완성, 심볼 리네임, 타입 추론 등 에디터 언어 서비스를 모두 잃게 만든다

any의 위험성 4: 타입 정의를 감추고 리팩터링 시 버그를 만든다

interface ComponentProps {
  onSelectItem: (item: any) => void; // any 사용
}
  • item의 타입이 바뀌어도 TypeScript가 알려주지 못한다
  • 올바른 타입을 지정했다면 리팩터링 시 관련된 모든 곳에서 오류를 잡을 수 있다

any는 타입 설계를 감춘다

  • 프로그램의 타입들을 잘 정의하는 것은 협업과 유지보수성의 핵심이다
  • any를 남용하면 그 설계가 감춰지고 문서로서의 코드 가치가 사라진다

핵심 정리

  • any는 TypeScript를 사실상 disable한다
  • any에는 타입 안전성이 없다
  • any를 사용하면 함수의 타입 계약을 파기할 수 있다
  • any 심볼에는 언어 서비스가 동작하지 않는다
  • any는 리팩터링 시 버그를 감춘다
  • any는 타입 시스템의 신뢰도를 떨어뜨린다

Chapter 2: TypeScript’s Type System

TypeScript의 타입 시스템은 매우 강력하고 표현력이 높다. 이 챕터는 타입 시스템의 기계적 원리를 깊이 이해하는 데 집중한다.

Item 6: 에디터로 타입 시스템 탐구하기

TypeScript를 설치하면 두 가지 실행 파일이 생긴다:

  • tsc: TypeScript 컴파일러
  • tsserver: TypeScript 언어 서버 (에디터에 언어 서비스를 제공)

에디터는 타입 시스템을 탐구하는 최고의 도구다.

에디터로 할 수 있는 일들

  • 심볼에 호버: TypeScript가 추론한 타입을 즉시 확인
  • 함수에 호버: 추론된 반환 타입 확인
  • 조건문 내 변수에 호버: 타입 내로잉(narrowing) 과정 확인
function foo(value: string | string[]) {
  if (Array.isArray(value)) {
    value;
    // ^? (parameter) value: string[]
  } else {
    value;
    // ^? (parameter) value: string
  }
}
  • 메서드 체인 중간 타입 확인: 제네릭 타입의 흐름을 추적
  • 타입 오류 통해 타입 시스템 학습: 직접 오류를 마주하며 언어 특성 이해

에디터 오류 예시: null 처리

function getElement(elOrId: string | HTMLElement | null): HTMLElement {
  if (typeof elOrId === 'object') {
    return elOrId;
    // ~~~ Type 'HTMLElement | null' is not assignable to type 'HTMLElement'
    // 이유: typeof null === 'object' 이므로 null이 걸러지지 않음!
  } else if (elOrId === null) {
    return document.body;
  }
  return document.getElementById(elOrId)!;
}
  • typeof null === 'object' 라는 JavaScript 특성을 에디터 오류를 통해 배울 수 있다

핵심 정리

  • 에디터를 통해 TypeScript가 어떤 타입을 추론하는지 항상 확인하라
  • 에디터의 타입 표시를 보면서 widening, narrowing에 대한 직관을 키워라
  • 타입 오류는 언어를 배우는 도구이기도 하다

Item 7: 타입을 값의 집합으로 생각하기

TypeScript에서 타입은 **가능한 값의 집합(domain)**이다. 이 관점으로 타입 연산의 의미가 분명해진다.

타입 계층: 집합 크기 기준

타입 집합 크기 예시
never 0 (공집합) 어떤 값도 할당 불가
리터럴 타입 1 type A = 'A'
유니온 타입 유한 type AB = 'A' | 'B'
number, string 무한 모든 숫자, 모든 문자열
unknown 최대 (전체집합) 모든 값 포함
const x: never = 12; // ~ Type 'number' is not assignable to type 'never'.

type A = 'A';
type B = 'B';
type AB = 'A' | 'B';
type AB12 = 'A' | 'B' | 12;

const a: AB = 'A';   // OK
const c: AB = 'C';   // ~ Type '"C"' is not assignable to type 'AB'

assignable의 의미: 부분집합

  • 값과 타입의 관계 → “값이 타입의 멤버인가?”
  • 타입과 타입의 관계 → “한 타입이 다른 타입의 부분집합인가?”
const ab: AB = Math.random() < 0.5 ? 'A' : 'B'; // OK: {'A','B'} ⊆ {'A','B'}
const ab12: AB12 = ab;  // OK: {'A','B'} ⊆ {'A','B',12}

declare let twelve: AB12;
const back: AB = twelve;
// ~~~~ Type 'AB12' is not assignable to type 'AB'
// 이유: {12}가 {'A','B'}의 부분집합이 아님

교차(intersection)와 유니온(union)의 직관

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

// PersonSpan에 속하는 값은 name과 birth를 모두 가져야 함
const ps: PersonSpan = {
  name: 'Alan Turing',
  birth: new Date('1912/06/23'),
  death: new Date('1954/06/07'),
};  // OK

핵심 인사이트: A & B의 값은 A의 프로퍼티와 B의 프로퍼티를 모두 가진다. 반면 A | Bkeyof는 두 타입 모두에 확실히 존재하는 키만 포함한다.

type K = keyof (Person | Lifespan);
//   ^? type K = never
// 이유: Person | Lifespan의 값에 확실히 있는 키가 없음

// 공식 관계:
// keyof (A & B) = (keyof A) | (keyof B)
// keyof (A | B) = (keyof A) & (keyof B)

extends는 “부분집합”

interface Person { name: string; }
interface PersonSpan extends Person {
  birth: Date;
  death?: Date;
}
// PersonSpan의 모든 값은 Person의 부분집합
// (PersonSpan이 더 좁은 집합)
  • extends로 필드를 좁혀서(더 구체화해서) 재정의할 수 있다
interface NullyStudent {
  name: string;
  ageYears: number | null;
}
interface Student extends NullyStudent {
  ageYears: number; // number | null → number로 좁힘: OK!
}

interface StringyStudent extends NullyStudent {
  //      ~~~~~~~~~~~~~~
  ageYears: number | string; // number | null → number | string로 확장: 오류!
}

핵심 정리

  • TypeScript 타입은 값의 집합(domain)이다
  • never는 공집합, 리터럴 타입은 단원소 집합, unknown은 전체집합
  • A & B 타입의 값은 AB 양쪽의 프로퍼티를 가진다
  • A | B 타입에 대해 keyof하면 공통 키만 남는다 (never가 될 수도 있음)
  • extendsassignable은 “부분집합”을 의미한다

Item 8: 심볼이 타입 공간인지 값 공간인지 구분하기

TypeScript에는 **타입 공간(type space)**과 **값 공간(value space)**이 공존한다. 같은 이름이 두 공간에 각각 존재할 수 있다.

두 공간의 기본 구조

// 타입 공간의 심볼 (컴파일 후 사라짐)
interface Cylinder {
  radius: number;
  height: number;
}

// 값 공간의 심볼 (컴파일 후 남음)
const Cylinder = (radius: number, height: number) => ({ radius, height });
  • 같은 이름 Cylinder가 서로 다른 공간에 존재하며 아무 관계도 없다
  • instanceof Cylinder는 값(함수)을 체크하므로 타입 Cylinder가 아닌 함수 Cylinder를 참조한다

공간 판별 방법

flowchart TD
    A[심볼 위치] --> B{type / interface 선언 내?}
    B -->|Yes| C[타입 공간]
    B -->|No| D{const / let / var 선언?}
    D -->|Yes| E[값 공간]
    D -->|No| F{: 다음 or as 다음?}
    F -->|Yes| C
    F -->|No| G{= 다음?}
    G -->|Yes| E
interface Person { first: string; last: string; }
const jane: Person = { first: 'Jane', last: 'Jacobs' };
//    값공간  타입공간  값공간

같은 연산자, 다른 의미

연산자/키워드 타입 공간 의미 값 공간 의미
typeof 값의 TypeScript 타입을 반환 런타임 타입 문자열 반환
& 교차 타입 (Intersection) 비트 AND
| 유니온 타입 (Union) 비트 OR
extends 서브타입 / 제네릭 제약 서브클래스
! non-null 단언 논리 부정(NOT)
as const 추론된 타입을 리터럴로 좁힘 없음
this 다형 this 타입 JavaScript this
type T1 = typeof jane;    // Person (TypeScript 타입)
const v1 = typeof jane;   // "object" (런타임 문자열)

type T2 = typeof email;   // (to: Person, subject: string, ...) => Response
const v2 = typeof email;  // "function" (런타임 문자열)

인덱스 접근도 다르다

const first: Person['first'] = jane['first']; // jane.first도 가능
//    값 공간              타입 공간
// Person['first']는 타입 (string)
// jane['first']는 값 ('Jane')

type PersonEl = Person['first' | 'last'];
//   ^? type PersonEl = string

type Tuple = [string, number, Date];
type TupleEl = Tuple[number];
//   ^? type TupleEl = string | number | Date

classenum은 양쪽 공간 모두 도입

class Cylinder {
  constructor(public radius: number, public height: number) {}
}

// 타입 공간: shape 타입으로 사용 가능
// 값 공간: instanceof 연산자로 사용 가능
function calculateVolume(shape: unknown) {
  if (shape instanceof Cylinder) {
    shape;
    // ^? (parameter) shape: Cylinder
    shape.radius;
    // ^? (property) Cylinder.radius: number
  }
}

구조 분해 시 타입/값 혼동 주의

// 잘못된 예: to, subject, body를 변수명으로 해석함
function email({
  to: Person,       // 'Person'이라는 변수를 만들려 함 → 오류
  subject: string,  // 'string'이라는 변수를 만들려 함 → 오류
  body: string
}) { /* ... */ }

// 올바른 예: 타입과 구조분해를 분리
function email(
  { to, subject, body }: { to: Person; subject: string; body: string }
) { /* ... */ }

핵심 정리

  • TypeScript에는 타입 공간과 값 공간이 공존한다; 에디터와 TypeScript playground로 감각을 익혀라
  • 모든 값은 정적 타입을 가지지만 타입 공간의 구성체는 런타임에 존재하지 않는다
  • class, enum은 타입과 값 모두를 도입한다
  • typeof, this, extends 등 많은 연산자와 키워드가 공간에 따라 다른 의미를 가진다

Item 9: 타입 단언보다 타입 애너테이션을 선호하기

TypeScript에서 변수에 타입을 부여하는 두 가지 방법:

interface Person { name: string; }

const alice: Person = { name: 'Alice' };  // 타입 애너테이션 (Type Annotation)
const bob = { name: 'Bob' } as Person;    // 타입 단언 (Type Assertion)

차이: 안전성 체크 여부

// 타입 애너테이션: 값이 타입에 맞는지 검증
const alice: Person = {};
// ~~~~~ Property 'name' is missing in type '{}' but required in type 'Person'

// 타입 단언: 체커를 침묵시킴 — 개발자가 "내가 더 잘 안다"고 선언
const bob = {} as Person; // No error
// 잉여 프로퍼티 체크도 단언은 무시함
const alice: Person = {
  name: 'Alice',
  occupation: 'TypeScript developer'
  // ~~~~~~~~~ Object literal may only specify known properties...
};

const bob = {
  name: 'Bob',
  occupation: 'JavaScript developer'
} as Person; // No error

화살표 함수에서의 타입 애너테이션

// 단언 방법 (권장하지 않음):
const people = ['alice', 'bob', 'jan'].map(name => ({ name } as Person));

// 반환 타입을 직접 지정하는 방법 (권장):
const people = ['alice', 'bob', 'jan'].map(
  (name): Person => ({ name })
); // Type is Person[]

// 외부 타입으로 체크하는 방법도 가능:
const people: Person[] = ['alice', 'bob', 'jan'].map(name => ({ name }));

주의: (name): Person은 “name의 타입을 추론하고 반환 타입을 Person으로 지정”하는 것이다. (name: Person)은 “name의 타입이 Person”이라는 전혀 다른 의미다.

타입 단언이 적합한 경우

개발자가 TypeScript보다 더 많은 컨텍스트 정보를 갖고 있을 때:

document.querySelector('#myButton')?.addEventListener('click', e => {
  e.currentTarget
  // ^? (property) Event.currentTarget: EventTarget | null
  // 우리는 #myButton이 button 요소임을 알고 있음
  const button = e.currentTarget as HTMLButtonElement;
  //    ^? const button: HTMLButtonElement
});

non-null 단언

const el = document.getElementById('foo'); // HTMLElement | null
const el2 = document.getElementById('foo')!; // HTMLElement (null 아님을 단언)
  • !를 접미사로 붙이면 해당 값이 null/undefined가 아님을 단언한다
  • 단언이 틀리면 런타임 오류 발생 → 값이 null일 수 없다는 확신이 있을 때만 사용

as는 관련 없는 타입 간에는 사용 불가

const el = document.getElementById('foo') as Person;
// ~~~ Conversion of type 'HTMLElement | null' to type 'Person' may be a mistake
// 두 타입이 서로 겹치는 부분이 없음
  • HTMLElementPerson은 공통 프로퍼티가 없어 단언 불가
  • 우회 방법: unknown을 중간에 삽입 (as unknown as Person) — 하지만 이는 매우 위험한 패턴이다

핵심 정리

  • 타입 단언보다 타입 애너테이션을 선호하라
  • 화살표 함수의 추론 타입이 의도와 다를 때는 반환 타입을 명시하라
  • 타입 단언은 TypeScript보다 개발자가 더 많은 정보를 가질 때만 사용하라
  • non-null 단언(!)은 null이 아님을 확신할 때만 사용하라

Item 10: 객체 래퍼 타입 피하기 (String, Number, Boolean, Symbol, BigInt)

JavaScript의 원시 타입에는 원시값객체 래퍼가 각각 존재한다.

원시값과 래퍼의 차이

// 원시 string 타입
const s: string = 'hello';

// String 객체 래퍼 (대문자)
const S: String = new String('hello');

typeof 'hello'        // "string"
typeof new String()   // "object"

'hello' === new String('hello') // false!
  • 원시값에서 메서드를 호출하면('hello'.toUpperCase()) JavaScript가 자동으로 래퍼 객체로 변환 후 메서드를 호출하고 다시 버린다

TypeScript에서 래퍼 타입 사용의 문제

function getStringLen(foo: String) {
  return foo.length;
}

getStringLen('hello');       // OK (string → String 자동 변환)
getStringLen(new String('hello')); // OK

// 반대는 안 됨!
function isGreeting(phrase: String) {
  return ['hello', 'good day'].includes(phrase);
  //                                    ~~~~~~
  // Argument of type 'String' is not assignable to parameter of type 'string'
}
  • stringString에 할당 가능하지만, Stringstring할당 불가
  • 항상 소문자 원시 타입(string, number, boolean)을 사용할 것

주의: 일부 래퍼는 new 없이 사용 가능

const bigInt = BigInt(1234n);  // OK, BigInt()는 new 없이 사용
const sym = Symbol('sym');     // OK, Symbol()도 new 없이 사용
  • String(), Number(), Boolean()new 없이 호출하면 원시값으로 변환하는 함수로 동작한다
  • TypeScript에서 Symbol, BigInt에 대한 타입으로 대문자 버전을 사용할 수도 있지만, 소문자를 쓰는 것이 더 안전하다

핵심 정리

  • 타입 애너테이션에는 항상 소문자 원시 타입(string, number, boolean)을 사용하라
  • 대문자 래퍼 타입(String, Number, Boolean)은 거의 사용할 일이 없다

Item 11: 잉여 프로퍼티 체크와 타입 체크를 구분하기

구조적 타이핑(Item 4)에 따르면, 추가 프로퍼티가 있는 객체는 더 좁은 타입에 할당 가능해야 한다. 그러나 TypeScript는 객체 리터럴을 직접 변수에 대입하거나 함수에 전달할 때 추가 검사를 수행한다 — 이것이 **잉여 프로퍼티 체크(Excess Property Checking)**다.

잉여 프로퍼티 체크가 발동하는 경우

interface Room {
  numDoors: number;
  ceilingHeightFt: number;
}

const r: Room = {
  numDoors: 1,
  ceilingHeightFt: 10,
  elephant: 'present',
  // ~~~~~~~ Object literal may only specify known properties,
  //         and 'elephant' does not exist in type 'Room'
};

잉여 프로퍼티 체크가 발동하지 않는 경우 (임시 변수 경유)

const obj = {
  numDoors: 1,
  ceilingHeightFt: 10,
  elephant: 'present',  // 오류 없음!
};
const r: Room = obj;  // OK — 구조적 타이핑으로만 체크됨
  • 객체 리터럴을 직접 타입이 지정된 변수에 할당할 때만 잉여 프로퍼티 체크가 발동한다
  • 임시 변수를 거치면 잉여 프로퍼티 체크는 우회되고 구조적 타이핑만 적용된다

함수 인자에서도 동일하게 동작

function setDarkMode(options: { title: string; darkMode?: boolean }) {
  // ...
}

setDarkMode({ title: 'TypeScript', darkMode: true });       // OK
setDarkMode({ title: 'TypeScript', darkmode: true });       // 오류! (darkmode ≠ darkMode)
// ~~~~~~~ Object literal may only specify known properties

const options = { title: 'TypeScript', darkmode: true };
setDarkMode(options); // OK! 잉여 프로퍼티 체크 우회

잉여 프로퍼티 체크의 한계

  • 약한(weak) 타입에도 유사한 검사가 적용된다: 모든 프로퍼티가 선택적인 타입
  • 타입 단언(as)을 사용하면 잉여 프로퍼티 체크가 적용되지 않는다
const r: Room = {
  numDoors: 1,
  ceilingHeightFt: 10,
  elephant: 'present',
} as Room; // No error (타입 단언 → 잉여 프로퍼티 체크 비활성)

핵심 정리

  • 잉여 프로퍼티 체크는 구조적 타이핑과는 별개의 추가 검사다
  • 객체 리터럴을 직접 할당할 때만 발동한다; 임시 변수를 경유하면 우회된다
  • 타입 단언이나 인덱스 시그니처를 사용하면 비활성화된다

Item 12: 함수 표현식 전체에 타입 적용하기

TypeScript에서 함수는 두 가지 방식으로 선언할 수 있다:

// 함수 문(Function Statement)
function rollDice1(sides: number): number { /* ... */ }

// 함수 표현식(Function Expression)
const rollDice2 = function(sides: number): number { /* ... */ };
const rollDice3 = (sides: number): number => { /* ... */ };

함수 타입을 전체에 적용하면 중복을 줄일 수 있다

type BinaryFn = (a: number, b: number) => number;

const add: BinaryFn = (a, b) => a + b;
const sub: BinaryFn = (a, b) => a - b;
const mul: BinaryFn = (a, b) => a * b;
const div: BinaryFn = (a, b) => a / b;
// a, b 파라미터 타입을 반복하지 않아도 됨
  • 파라미터 타입을 반복하지 않아도 되므로 DRY 원칙 적용 가능
  • 함수 구현에 실수가 있으면 오류가 한 곳에서만 발생

기존 함수의 타입 재사용: typeof fn

async function checkedFetch(input: RequestInfo, init?: RequestInit): Promise<Response> {
  const response = await fetch(input, init);
  if (!response.ok) {
    throw new Error(`Request failed: ${response.status}`);
  }
  return response;
}

// typeof 로 fetch의 타입을 그대로 가져와 사용:
const checkedFetch: typeof fetch = async (input, init) => {
  const response = await fetch(input, init);
  if (!response.ok) throw new Error(`Request failed: ${response.status}`);
  return response;
};
  • typeof fetch를 사용하면 fetch 시그니처가 바뀌어도 타입이 자동으로 동기화된다
  • 타입이 파라미터에서 추론되므로 파라미터 타입을 따로 선언할 필요가 없다

라이브러리의 함수 타입 재사용

// MouseEventHandler 같은 라이브러리 타입을 직접 활용
const handleClick: MouseEventHandler = (e) => {
  // e의 타입이 자동으로 MouseEvent로 추론됨
};

핵심 정리

  • 파라미터 타입과 반환 타입을 반복하는 대신, 함수 전체에 타입을 적용하라
  • 같은 시그니처를 가진 여러 함수가 있다면 함수 타입을 정의해서 공유하라
  • 다른 함수의 시그니처를 따르려면 typeof fn을 활용하라

Item 13: typeinterface의 차이 알기

TypeScript에서 타입을 이름짓는 두 가지 방법:

type TState = { name: string; capital: string; };
interface IState { name: string; capital: string; }

공통점

  • 인덱스 시그니처, 함수 타입, 제네릭, 확장(extends/&), 클래스 구현 모두 가능
  • 대부분 상황에서 서로 대체 가능

interface에서만 가능한 것: 선언 병합(Declaration Merging)

interface IState { name: string; capital: string; }
interface IState { population: number; }

const wyoming: IState = {
  name: 'Wyoming',
  capital: 'Cheyenne',
  population: 578_000,
}; // OK — 두 선언이 합쳐짐
  • 라이브러리의 타입을 **보강(augment)**할 때 유용하다
  • 하지만 일반 코드에서 같은 이름을 두 번 선언하면 의도치 않은 병합이 발생할 수 있다

type에서만 가능한 것들

// 1. 유니온 타입
type StringOrNumber = string | number;

// 2. 튜플 타입 (더 깔끔한 문법)
type Pair = [number, number];
type StringList = string[];
type NamedNums = [string, ...number[]];

// 3. 매핑된 타입, 조건부 타입 등 복잡한 타입 연산
type Readonly<T> = { readonly [K in keyof T]: T[K]; };
  • interface로도 튜플을 흉내낼 수 있지만 length 등의 메서드가 없어서 불완전하다

언제 무엇을 쓸까?

  • 기본 원칙: 가능하면 interface를 사용하고, 유니온 타입이나 복잡한 타입 연산이 필요하면 type을 사용
  • 라이브러리 공개 API: interface 선호 (사용자가 선언 병합으로 확장 가능)
  • 프로젝트 내부 타입: 둘 중 하나로 일관성 있게 사용하면 됨
  • 이름에 I/T 접두사는 비권장 (IState, TState처럼 쓰지 말 것 — 안티패턴)

핵심 정리

  • typeinterface의 차이를 알고, 각 상황에 맞는 것을 선택하라
  • interface는 선언 병합이 가능하다
  • type은 유니온, 튜플, 복잡한 타입 연산에서 더 유연하다
  • 프로젝트 내에서 일관된 스타일을 유지하라

Item 14: 변이(Mutation)와 관련된 오류를 방지하기 위해 readonly 사용하기

readonly는 프로퍼티나 배열이 수정되지 않아야 함을 타입 시스템으로 강제한다.

배열의 readonly

function arraySum(arr: readonly number[]) {
  let sum = 0, num;
  while ((num = arr.pop()) !== undefined) {
    //             ~~~ Property 'pop' does not exist on type 'readonly number[]'
    sum += num;
  }
  return sum;
}
  • readonly number[]number[]의 메서드 중 배열을 변경하는 것들(push, pop 등)을 제거한 타입이다
  • number[]readonly number[]에 할당 가능하지만, 역은 불가
const a: number[] = [1, 2, 3];
const b: readonly number[] = a;  // OK
const c: number[] = b;
//    ~ Type 'readonly number[]' is not assignable to type 'number[]'

readonlyconst의 차이

const arr = [1, 2, 3]; // arr 변수 자체를 재할당 불가, 하지만 내부 변경은 가능
arr.push(4); // OK!

const readonlyArr: readonly number[] = [1, 2, 3];
readonlyArr.push(4); // 오류!
  • const: 변수 바인딩이 변경 불가 (재할당 불가)
  • readonly: 객체/배열의 내부 값 변경 불가

객체의 readonly 프로퍼티

interface Outer {
  inner: {
    x: number;
  }
}
const o: Readonly<Outer> = { inner: { x: 0 }};
o.inner = { x: 1 }; // 오류! inner 자체를 바꿀 수 없음
o.inner.x = 1;       // OK! 얕은(shallow) readonly → inner 안의 값은 변경 가능

주의: readonlyReadonly<T>얕은(shallow) 불변성만 보장한다. 깊은 불변성을 원한다면 별도 라이브러리(예: ts-essentialsDeepReadonly)가 필요하다.

함수 파라미터에 readonly 적용 → 의도 명확화

function printTriangles(n: number) {
  const nums: number[] = [];
  for (let i = 0; i <= n; i++) {
    nums.push(i);
    console.log(arraySum(nums)); // arraySum이 nums를 변경한다면 버그!
  }
}

// arraySum을 readonly로 선언하면 내부 변경 의도가 없음을 명시하고
// 실수로 변경 시 컴파일 오류로 잡을 수 있다
function arraySum(arr: readonly number[]) {
  let sum = 0;
  for (const num of arr) sum += num;
  return sum;
}

핵심 정리

  • 함수가 파라미터를 변경하지 않는다면 readonly로 선언하라
  • readonly는 타입 오류를 더 명확하게 만들고, 코드의 의도를 문서화한다
  • readonly는 얕은 불변성만 보장한다
  • constreadonly의 차이: const는 바인딩, readonly는 내부 값

Item 15: 타입 연산과 제네릭으로 반복 줄이기

DRY(Don’t Repeat Yourself) 원칙은 타입에도 동일하게 적용된다.

타입 반복 줄이기: typeof로 값에서 타입 추출

const INIT_OPTIONS = {
  width: 640,
  height: 480,
  color: '#00FF00',
  label: 'VGA',
};

// 위 값의 구조를 수동으로 중복 선언하지 말고:
type Options = typeof INIT_OPTIONS;
//   ^? type Options = { width: number; height: number; color: string; label: string; }

함수 반환 타입 추출: ReturnType

function getUserInfo(userId: string) {
  return { userId, name, age };
}

type UserInfo = ReturnType<typeof getUserInfo>;
// 함수 시그니처가 바뀌어도 UserInfo가 자동으로 동기화됨

extends로 인터페이스 필드 재사용

interface Person { firstName: string; lastName: string; }
interface PersonWithBirthDate extends Person { birth: Date; }
// Person의 필드를 반복하지 않아도 됨

인덱싱으로 타입 참조

interface State {
  userId: string;
  pageTitle: string;
  recentFiles: string[];
  pageContents: string;
}

// 일부 필드를 갖는 타입 정의 시 직접 참조:
type TopNavState = {
  userId: State['userId'];
  pageTitle: State['pageTitle'];
  recentFiles: State['recentFiles'];
};

매핑된 타입(Mapped Types)으로 더 간결하게

// Pick 유틸리티 타입
type TopNavState = Pick<State, 'userId' | 'pageTitle' | 'recentFiles'>;

Partial을 활용한 업데이트 파라미터

interface Options {
  width: number;
  height: number;
  color: string;
  label?: string;
}

// OptionsUpdate 직접 만들기 (매핑된 타입)
type OptionsUpdate = { [k in keyof Options]?: Options[k] };
// 또는 표준 라이브러리 사용:
class UIWidget {
  constructor(init: Options) { /* ... */ }
  update(options: Partial<Options>) { /* ... */ }
}

키 이름 변환: as 절 활용 (매핑된 타입)

interface ShortToLong {
  q: 'search';
  n: 'numberOfResults';
}
type LongToShort = { [k in keyof ShortToLong as ShortToLong[k]]: k };
//   ^? type LongToShort = { search: "q"; numberOfResults: "n"; }

keyof 관계

type OptionsKeys = keyof Options;
//   ^? type OptionsKeys = "width" | "height" | "color" | "label"

동형(Homomorphic) 매핑된 타입

  • K in keyof T 형태의 매핑된 타입은 readonly, ? 수식어와 JSDoc을 원본 타입에서 복사해온다
  • Pick, Partial 등의 표준 유틸리티 타입은 모두 동형이다
interface Customer {
  /** 고객 호칭 */
  title?: string;
  /** 시스템에 등록된 전체 이름 */
  readonly name: string;
}

type PickTitle = Pick<Customer, 'title'>;
//   ^? type PickTitle = { title?: string }  → optional 수식어 보존됨

type PickName = Pick<Customer, 'name'>;
//   ^? type PickName = { readonly name: string }  → readonly 보존됨

추상화 주의: 잘못된 DRY

// 나쁜 예: id와 name이 우연히 같은 타입일 뿐, 의미상 같은 것이 아님
interface Product { id: number; name: string; priceDollars: number; }
interface Customer { id: number; name: string; address: string; }

// 이런 추상화는 하지 말 것:
interface NamedAndIdentified { id: number; name: string; }
  • 타입이 구조적으로 같더라도 의미가 다르면 공통 기반 타입을 추출하는 것은 조기 추상화가 될 수 있다
  • 이름짓기 어려운 타입은 유용하지 않은 추상화일 가능성이 높다

핵심 정리

  • DRY 원칙은 타입에도 적용된다
  • 타입 이름을 직접 짓는 대신 반복을 피하라; extends로 인터페이스 필드를 재사용하라
  • Partial, Readonly, Pick, Record 같은 표준 유틸리티 타입을 활용하라
  • 함수의 반환 타입을 이름 짓고 싶다면 ReturnType을 사용하라
  • 잘 네이밍되지 않는 타입 추상화는 오히려 코드를 경직시킬 수 있다

Item 16: 인덱스 시그니처보다 더 정밀한 대안 선호하기

인덱스 시그니처의 문제점

type Rocket = { [property: string]: string };
const rocket: Rocket = {
  name: 'Falcon 9',
  variant: 'v1.0',
  thrust: '4,940 kN',
};  // OK

// 문제들:
// 1. 빈 객체도 허용: {}도 Rocket 타입
// 2. 어떤 key도 허용: rocket['notExist']가 string | undefined가 아닌 string으로 추론됨
// 3. 특정 키만 허용 불가: 'name', 'variant', 'thrust' 외에도 모두 허용됨
// 4. 값의 타입이 달라질 수 없음: 모든 값이 string이어야 함

대안 1: 정확히 알고 있다면 interface 사용

interface Rocket {
  name: string;
  variant: string;
  thrust_kN: number;
}
const falconHeavy: Rocket = {
  name: 'Falcon Heavy',
  variant: 'v1',
  thrust_kN: 15_200
};

대안 2: 런타임에 키를 알 수 없는 경우 → Record 또는 Map 사용

// 키와 값의 타입만 알고 실제 키가 몇 개인지 모를 때
type WordCounts = Record<string, number>;

// 또는 특정 키 집합을 유니온으로 제한:
type RGB = Record<'red' | 'green' | 'blue', number>;

대안 3: 키 타입을 유니온으로 제한

// 특정 key들만 허용하고 싶다면
type Vec3D = { [k in 'x' | 'y' | 'z']: number };
// type Vec3D = { x: number; y: number; z: number; } 와 동일

// 키에 따라 값 타입이 달라진다면 (매핑된 타입 + 조건부 타입)
type ABC = { [k in 'a' | 'b' | 'c']: k extends 'b' ? string : number };
// ^? type ABC = { a: number; b: string; c: number; }

핵심 정리

  • 인덱스 시그니처는 런타임까지 키를 알 수 없을 때만 사용하라
  • 가능하다면 interface, Record, 또는 매핑된 타입 같은 더 정밀한 대안을 사용하라

Item 17: 숫자형 인덱스 시그니처 피하기

JavaScript에서 객체의 키는 **항상 문자열(또는 Symbol)**이다. 숫자로 인덱싱하면 JavaScript가 자동으로 문자열로 변환한다.

const xs = [1, 2, 3];
xs          // 1
xs['0']        // 1 — 같은 결과!
Object.keys(xs) // ['0', '1', '2'] — 문자열로 반환!

TypeScript에서 배열 타입과 숫자 인덱스

TypeScript는 배열 인덱싱에 number 타입을 요구한다:

const xs = [1, 2, 3];
const x0 = xs;    // OK
const x1 = xs['1'];  // ~ Element implicitly has an 'any' type
  • 이는 TypeScript가 만든 편의상의 픽션이다; 실제 런타임에서는 문자열로 변환된다
  • for...in 루프로 배열을 순회하면 키가 문자열로 나온다

숫자형 인덱스 시그니처의 혼란

function get<T>(array: T[], k: string): T {
  return array[k];
  //           ~ Element implicitly has an 'any' type because
  //             index expression is not of type 'number'
}

권장 대안

// Array, 튜플, ArrayLike 등을 사용하라
// 숫자 인덱스만 필요하고 push 등의 메서드가 필요 없다면:
function checkedAccess<T>(xs: ArrayLike<T>, i: number): T {
  if (i < 0 || i >= xs.length) {
    throw new Error(`Attempt to access ${i} which is past end of array.`);
  }
  return xs[i];
}

배열 순회 방법 선택

const xs = [1, 2, 3];

// 인덱스가 필요 없다면:
for (const x of xs) { /* x는 number */ }

// 인덱스와 값이 모두 필요하다면:
xs.forEach((x, i) => { /* x는 number, i는 number */ });

// 인덱스로 중간에 루프를 멈춰야 한다면:
for (let i = 0; i < xs.length; i++) { /* i는 number, xs[i]는 number */ }

// 피할 것: for...in (인덱스가 string으로 나옴)
for (const k in xs) {
  // k의 타입은 string!
}

핵심 정리

  • JavaScript(따라서 TypeScript도)의 배열은 사실 객체이며 키는 문자열이다
  • TypeScript에서 숫자형 인덱스는 편의상 제공되는 픽션이다
  • 배열 순회에는 for...of, forEach, 또는 for 루프를 사용하라; for...in은 피하라
  • 인덱스 타입에 숫자를 넣는 시그니처가 필요하다면 ArrayLike를 고려하라

Chapter 3: 타입 추론과 제어 흐름 분석

TypeScript는 강력한 타입 추론 시스템을 갖추고 있다. 숙련된 개발자는 타입 어노테이션을 적게 쓰되 효과적으로 사용하며, 초보자는 불필요한 어노테이션으로 코드를 도배하는 경향이 있다. 변수는 코드의 “위치”에 따라 다른 타입을 가질 수 있으며, 이 변화 과정을 제어 흐름 분석(control flow analysis) 이라 한다.

Item 18: 추론 가능한 타입에 어노테이션을 남발하지 말라

핵심 원칙: TypeScript가 이미 알고 있는 타입을 다시 명시하는 것은 소음(noise)이다.

  • 불필요한 어노테이션의 예:
// 나쁜 예
let x: number = 12;

// 좋은 예
let x = 12; // TypeScript가 number로 추론
  • 객체나 배열도 마찬가지로 타입 추론이 잘 동작한다:
// 불필요하게 장황한 버전
const person: { name: string; born: { where: string; when: string } } = {
  name: 'Sojourner Truth',
  born: { where: 'Swartekill, NY', when: 'c.1797' },
};

// 충분한 버전 - TypeScript가 동일하게 추론
const person = {
  name: 'Sojourner Truth',
  born: { where: 'Swartekill, NY', when: 'c.1797' },
};
  • 타입 추론이 리팩토링을 쉽게 만든다. Product.id 타입을 number에서 string으로 바꿀 때, 함수 본체에 명시적 어노테이션이 없으면 변경 없이 통과한다:
// id 타입이 string으로 변경되어도 에러 없음
function logProduct(product: Product) {
  const { id, name, price } = product; // 타입 추론에 맡김
  console.log(id, name, price);
}

어노테이션이 여전히 필요한 경우:

  • 함수/메서드 파라미터 (기본값이 있는 경우 제외)
  • 객체 리터럴 정의 시: 초과 프로퍼티 검사(excess property check)를 활성화하고, 에러 위치를 정의 지점에 고정한다
const elmo: Product = {
  name: 'Tickle Me Elmo',
  id: '048188 627152',
  price: 28.99,
};
  • 함수 반환 타입: 다음 중 하나라도 해당하면 명시한다
    • 여러 return 구문이 있는 경우
    • 공개 API의 일부인 경우
    • 명명된 반환 타입을 사용하고 싶은 경우

실수 방지 예시: getQuote 함수에 Promise<number> 반환 타입을 명시하면, 캐시에서 number를 직접 반환하는 실수를 함수 내부에서 잡아낼 수 있다. 명시하지 않으면 에러가 호출 지점에서 터진다.

기억할 것:

  • 이상적인 TypeScript 코드: 함수/메서드 시그니처에는 타입 어노테이션, 본체의 지역 변수에는 타입 어노테이션 없음
  • linter 규칙 no-inferrable-types(typescript-eslint)를 사용하면 불필요한 어노테이션을 자동으로 잡아낼 수 있다

Item 19: 다른 타입에는 다른 변수를 사용하라

핵심 통찰: 변수의 값은 바뀔 수 있지만 타입은 일반적으로 바뀌지 않는다.

// 자바스크립트에서는 가능하지만 TypeScript에서는 에러
let productId = '12-34-56';
fetchProduct(productId);
productId = 123456; // Error: Type 'number' is not assignable to type 'string'
fetchProductBySerialNumber(productId);
  • string | number 유니온으로 해결할 수 있지만, 더 좋은 해결책은 별도 변수를 사용하는 것이다:
const productId = '12-34-56';
fetchProduct(productId);

const serial = 123456;
fetchProductBySerialNumber(serial);

별도 변수를 사용했을 때의 이점:

  • 두 개의 관련 없는 개념(ID, 시리얼 번호)을 분리
  • 더 구체적인 변수명 사용 가능
  • 타입 추론 개선, 어노테이션 불필요
  • 더 단순한 타입(string, number) 사용
  • const로 선언 가능 → 타입 체커와 사람 모두 추론이 쉬워짐

섀도잉(shadowing)과의 구분: 블록 스코프로 같은 이름을 재사용하는 것은 TypeScript는 괜찮지만 사람이 혼란스러워진다. no-shadow linter 규칙으로 금지하는 팀이 많다.

기억할 것: 타입이 바뀌는 변수를 피하라. 다른 개념에 다른 이름을 사용하면 코드가 명확해진다. let보다 const를 훨씬 많이 사용해야 한다.

Item 20: 변수가 타입을 얻는 방식을 이해하라

TypeScript가 리터럴 값에서 타입을 결정하는 과정을 넓히기(widening) 라고 한다.

넓히기 문제 예시:

interface Vector3 { x: number; y: number; z: number; }
function getComponent(vector: Vector3, axis: 'x' | 'y' | 'z') {
  return vector[axis];
}

let x = 'x'; // TypeScript는 string으로 추론 (not 'x')
let vec = { x: 10, y: 20, z: 30 };
getComponent(vec, x); // Error: Argument of type 'string' is not assignable to parameter of type '"x" | "y" | "z"'

넓히기를 제어하는 4가지 방법:

  1. const 사용: 더 좁은 타입으로 추론
const x = 'x'; // 타입: 'x' (string 아님)
  1. 명시적 타입 어노테이션:
const obj: { x: string | number } = { x: 1 };
  1. as const 어서션: 가장 좁은 타입, 모든 프로퍼티를 readonly로 만듦
const obj1 = { x: 1, y: 2 };         // { x: number; y: number }
const obj2 = { x: 1, y: 2 } as const; // { readonly x: 1; readonly y: 2 }

const arr1 = [1, 2, 3];         // number[]
const arr2 = [1, 2, 3] as const; // readonly [1, 2, 3]
  1. satisfies 연산자: 타입 조건을 만족시키면서 넓히기를 방지하고 정확한 키를 유지
type Point = [number, number];
const capitals = {
  ny: [-73.75, 42.65],
  ca: [-121.49, 38.58],
} satisfies Record<string, Point>;
// capitals.ny는 number[] 아닌 [number, number]로 추론
// capitals.pr → 컴파일 에러 (존재하지 않는 키)

satisfies vs 타입 어노테이션: 어노테이션(const c: Record<string, Point> = ...)은 c.prPoint로 보여 런타임 에러를 놓칠 수 있지만, satisfies는 정확한 키를 유지하면서 타입 검사를 수행한다.

기억할 것: TypeScript의 넓히기 동작을 이해하고, const, 타입 어노테이션, as const, satisfies로 동작을 조절하라.

Item 21: 객체는 한 번에 만들어라

TypeScript에서 변수의 타입은 일반적으로 바뀌지 않으므로, 객체를 조각조각 만드는 패턴은 에러를 유발한다.

// 에러 발생
const pt = {};
pt.x = 3; // Error: Property 'x' does not exist on type '{}'
pt.y = 4; // Error

// 올바른 방법
const pt: Point = { x: 3, y: 4 };

여러 객체를 합칠 때: Object.assign 대신 spread 문법을 사용한다:

const pt = { x: 3, y: 4 };
const id = { name: 'Pythagoras' };

// 나쁜 예
const namedPoint = {};
Object.assign(namedPoint, pt, id);
namedPoint.name; // Error

// 좋은 예
const namedPoint = { ...pt, ...id };
namedPoint.name; // OK, string으로 추론

조건부로 프로퍼티를 추가할 때는 spread와 falsy 값을 활용한다:

declare let hasMiddle: boolean;
const firstLast = { first: 'Harry', last: 'Truman' };
const president = {
  ...firstLast,
  ...(hasMiddle ? { middle: 'S' } : {}),
};
// 타입: { middle?: string; first: string; last: string }

기억할 것: 객체는 한 번에 만들어라. { ...a, ...b } spread 문법으로 타입 안전하게 합쳐라. 조건부 프로퍼티 추가 방법을 알아두어라.

Item 22: 타입 좁히기를 이해하라

좁히기(narrowing/refinement): TypeScript가 넓은 타입에서 더 좁은 타입으로 이동하는 과정이다.

좁히기 방법들:

  1. null 체크:
const elem = document.getElementById('what-time-is-it');
// elem: HTMLElement | null
if (elem) {
  elem.innerHTML = 'Party Time'; // elem: HTMLElement
}
  1. throw / return으로 분기:
if (!elem) throw new Error('Unable to find');
elem.innerHTML = '...'; // elem: HTMLElement
  1. instanceof:
if (search instanceof RegExp) { /* search: RegExp */ }
  1. in 연산자를 이용한 프로퍼티 체크:
if ('isGoodForBaking' in fruit) { /* fruit: Apple */ }
  1. Array.isArray 같은 내장 함수
  2. 태그드 유니온(tagged/discriminated union) - TypeScript에서 가장 강력한 패턴:
interface UploadEvent { type: 'upload'; filename: string; contents: string; }
interface DownloadEvent { type: 'download'; filename: string; }
type AppEvent = UploadEvent | DownloadEvent;

function handleEvent(e: AppEvent) {
  switch (e.type) {
    case 'download': console.log(e.filename); break; // e: DownloadEvent
    case 'upload': console.log(e.contents); break;   // e: UploadEvent
  }
}
  1. 사용자 정의 타입 가드(user-defined type guard):
function isInputElement(el: Element): el is HTMLInputElement {
  return 'value' in el;
}

좁히기의 함정들:

  • typeof null === 'object'이므로 typeof elem === 'object' 체크는 null을 제거하지 못한다
  • !x 체크는 0이나 ''도 걸러내므로 의도치 않게 타입이 좁혀지지 않을 수 있다
  • 콜백 내부에서는 좁히기가 무효화될 수 있다: 콜백이 실행되는 시점에 외부 값이 변경될 수 있기 때문이다

기억할 것: TypeScript의 좁히기 방식을 이해하라. 타입 가드와 태그드 유니온을 활용하라. TypeScript가 따라오기 쉽도록 코드를 리팩토링하는 방법을 생각하라.

Item 23: 별칭은 일관되게 사용하라

객체의 프로퍼티에 새 변수(별칭)를 만들면 제어 흐름 분석이 방해받는다.

// 문제: bbox는 별칭이지만 polygon.bbox로 체크
function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
  const box = polygon.bbox;   // BoundingBox | undefined
  if (polygon.bbox) {         // polygon.bbox는 좁혀지지만
    if (pt.x < box.x) {   // box는 여전히 undefined 가능 - Error!
    }
  }
}

// 해결: 별칭으로 일관되게 체크
function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
  const box = polygon.bbox;
  if (box) {                  // box로 체크하면 box도 좁혀짐
    if (pt.x < box.x) {   // OK
    }
  }
}

// 더 좋은 방법: 구조 분해로 이름 일관성 확보
function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
  const { bbox } = polygon;
  if (bbox) {
    const { x, y } = bbox;
    // ...
  }
}

추가 주의사항:

  • 별칭이 있을 때 함수 호출은 프로퍼티의 타입 좁히기를 무효화할 수 있다 (함수가 값을 변경할 수도 있기 때문)
  • 로컬 변수의 좁히기는 신뢰할 수 있지만, 프로퍼티의 좁히기는 주의가 필요하다
  • 변경 가능성이 우려된다면 readonly 버전을 함수에 전달하라

기억할 것: 별칭은 TypeScript의 좁히기를 방해한다. 별칭을 만들었으면 일관되게 사용하라. 함수 호출이 프로퍼티의 타입 좁히기를 무효화할 수 있다는 점을 인지하라.

Item 24: 컨텍스트가 타입 추론에 어떻게 사용되는지 이해하라

TypeScript는 값 자체뿐 아니라 값이 사용되는 컨텍스트(문맥) 도 참고하여 타입을 추론한다. 값을 변수로 분리하면 컨텍스트가 끊겨 예상치 못한 에러가 발생한다.

문자열 리터럴 타입 문제:

type Language = 'JavaScript' | 'TypeScript' | 'Python';
function setLanguage(language: Language) { ... }

setLanguage('JavaScript'); // OK

let language = 'JavaScript'; // 타입: string (넓어짐)
setLanguage(language); // Error: string은 Language에 할당 불가

해결 방법:

// 방법 1: 타입 어노테이션
let language: Language = 'JavaScript';

// 방법 2: const 사용 (리터럴 타입 유지)
const language = 'JavaScript'; // 타입: 'JavaScript'

튜플 타입 문제:

function panTo(where: [number, number]) { ... }
const loc = [10, 20]; // number[] 로 추론
panTo(loc); // Error

// 해결 1: 타입 어노테이션
const loc: [number, number] = [10, 20];

// 해결 2: as const + panTo에 readonly 추가
function panTo(where: readonly [number, number]) { ... }
const loc = [10, 20] as const; // readonly [10, 20]
panTo(loc); // OK

as const의 단점: 실수가 정의 지점이 아니라 사용 지점에서 에러를 낸다.

객체 문제:

type Language = 'JavaScript' | 'TypeScript' | 'Python';
interface GovernedLanguage { language: Language; organization: string; }

const ts = { language: 'TypeScript', organization: 'Microsoft' }; // language: string으로 추론
complain(ts); // Error

// 해결: 타입 어노테이션, as const, 또는 satisfies 사용
const ts: GovernedLanguage = { language: 'TypeScript', organization: 'Microsoft' };

콜백 문제:

// 인라인 형태: 타입 추론 OK
callWithRandomNumbers((a, b) => { // a, b: number로 추론
  console.log(a + b);
});

// 분리하면 컨텍스트 손실
const fn = (a, b) => { // Error: implicit any
  console.log(a + b);
};
// 해결: 파라미터 타입 명시
const fn = (a: number, b: number) => { console.log(a + b); };

기억할 것: 변수 분리가 타입 에러를 유발하면 타입 어노테이션을 추가하라. 진짜 상수라면 as const를 사용하되, 에러가 사용 지점에서 나타날 수 있음을 인지하라. 가능하면 인라인으로 값을 사용하는 것이 어노테이션 필요성을 줄인다.

Item 25: 진화하는 타입을 이해하라

일반적으로 변수의 타입은 선언 시점에 고정되지만, 초기값이 빈 배열([])이거나 null/undefined인 경우 타입이 진화(evolve)한다.

배열의 타입 진화:

function range(start: number, limit: number) {
  const nums = []; // 타입: any[]
  for (let i = start; i < limit; i++) {
    nums.push(i); // 타입: number[]
  }
  return nums; // 타입: number[]
}
  • 값을 push할 때마다 타입이 확장된다
  • 서로 다른 타입을 push하면 유니온으로 확장: string[] → (string | number)[]
  • 이 패턴을 “진화하는 any”, “evolving arrays”, “evolving let” 이라고도 부른다

스칼라 값의 타입 진화:

let value = null; // 타입: any (진화 중)
try {
  value = doSomethingRisky(); // 타입: number (진화됨)
} catch (e) {
  // value: number | null
}

진화가 일어나지 않는 경우:

  • concat 메서드는 진화를 트리거하지 않는다
  • 함수 호출을 통한 암묵적 any는 진화하지 않는다 (forEach 등)
  • for-offorEach보다 선호되는 이유 중 하나

주의: 진화하는 타입을 읽기 전에 쓰면 암묵적 any 에러가 발생한다. 더 나은 에러 검사를 원한다면 명시적 타입 어노테이션을 사용하거나, map 같은 함수형 구조를 통해 진화 자체를 피하는 것이 좋다.

Item 26: 타입 흐름을 돕기 위해 함수형 구조와 라이브러리를 활용하라

루프 대신 내장 함수형 메서드와 Lodash 같은 라이브러리를 사용하면 타입 어노테이션 없이도 타입이 자연스럽게 흐른다.

루프 방식 vs 함수형 방식:

// 루프 방식: 타입 어노테이션 필요
let allPlayers: BasketballPlayer[] = [];
for (const players of Object.values(rosters)) {
  allPlayers = allPlayers.concat(players);
}

// 함수형 방식: 어노테이션 불필요, const 사용 가능
const allPlayers = Object.values(rosters).flat();
// 타입: BasketballPlayer[] (자동 추론)

Lodash 체이닝의 타입 안전성:

const bestPaid = _(allPlayers)
  .groupBy(player => player.team)
  .mapValues(players => _.maxBy(players, p => p.salary)!)
  .values()
  .sortBy(p => -p.salary)
  .value();
// 타입: BasketballPlayer[] - 자동으로 정확하게 추론
  • 함수형 구조는 불변성(immutability)을 기반으로 매 호출마다 새 값을 반환 → 새 타입도 자연스럽게 생성
  • TypeScript 개발 자체가 JavaScript 라이브러리의 동작을 정확히 모델링하려는 시도로 이루어짐

Item 27: 콜백 대신 async 함수를 사용하라

비동기 처리 방식의 발전: 콜백 → Promise → async/await

// 콜백 방식: 타입 추론 어려움, 에러 처리 복잡
fetchURL(url1, (response1) => {
  fetchURL(url2, (response2) => { ... });
});

// async/await 방식: 타입 추론 자연스러움
async function fetchPages() {
  const [response1, response2, response3] = await Promise.all([
    fetch(url1), fetch(url2), fetch(url3)
  ]);
  // 각 response 타입이 Response로 정확히 추론됨
}
  • Promise.raceasync/await로 타임아웃 구현:
function timeout(ms: number): Promise<never> {
  return new Promise((_, reject) => setTimeout(() => reject('timeout'), ms));
}
async function fetchWithTimeout(url: string, ms: number) {
  return Promise.race([fetch(url), timeout(ms)]);
  // 반환 타입: Promise<Response> 자동 추론 (Promise<never>와의 유니온에서 never 제거)
}

async 함수의 이점:

  • 항상 Promise를 반환하므로 동기/비동기 혼합 반환 실수를 방지
  • 타입 흐름이 더 자연스럽고 에러 처리 클래스 전체를 제거
  • 함수가 Promise를 반환한다면 async로 선언하라

Item 28: 새로운 추론 지점을 만들기 위해 클래스와 커링을 활용하라

TypeScript의 타입 파라미터 추론은 전부 추론하거나 전부 명시해야 하는 양자택일 방식이다.

문제 상황:

declare function fetchAPI<API, Path extends keyof API>(
  path: Path
): Promise<API[Path]>;

fetchAPI<SeedAPI>('seedstrawberry'); // Error: 타입 인수 2개 필요

해결 1: 클래스 활용

class ApiFetcher<API> {
  fetch<Path extends keyof API>(path: Path): Promise<API[Path]> { ... }
}
const fetcher = new ApiFetcher<SeedAPI>();
fetcher.fetch('seedstrawberry'); // OK

해결 2: 커링 함수 활용 (로컬 타입 별칭도 가능)

declare function apiFetcher<API>(): {
  fetch<Path extends keyof API>(path: Path): Promise<API[Path]>
};
const fetcher = apiFetcher<SeedAPI>();
fetcher.fetch('seedstrawberry'); // OK

커링 방식의 장점: 함수 구현 스코프 내에서 로컬 타입 별칭을 정의할 수 있다. 이는 복잡한 타입 표현식의 반복을 줄인다. 클래스로는 불가능하다.

기억할 것: 여러 타입 파라미터가 있을 때 추론은 전부 아니면 전부다. 부분 추론이 필요하면 클래스나 커링을 사용하라. 로컬 타입 별칭이 필요하다면 커링 방식을 선호하라.

Chapter 4: 타입 설계

“나에게 테이블을 보여주면 플로우차트가 필요 없다. 플로우차트를 보여주면 나는 여전히 미궁이다.” - Fred Brooks

타입을 잘 설계하면 코드의 동작이 명확해지고, 잘못 설계하면 문서나 영리함도 구할 수 없다. 이 챕터는 타입 자체의 설계 원칙을 다룬다.

Item 29: 유효한 상태만 표현하는 타입을 선호하라

타입 설계의 핵심은 유효하지 않은 상태를 표현할 수 없는 타입을 만드는 것이다.

나쁜 설계 예시:

interface State {
  pageText: string;
  isLoading: boolean;
  error?: string;
}
  • isLoading: true이면서 error가 있는 상태가 가능 → 무효한 상태
  • renderPage, changePage 구현 시 무엇을 표시해야 할지 모호함
  • 실제 버그: isLoading을 false로 되돌리는 것을 잊거나, 이전 에러가 남아있는 등

좋은 설계 예시 - 태그드 유니온 활용:

interface RequestPending { state: 'pending'; }
interface RequestError { state: 'error'; error: string; }
interface RequestSuccess { state: 'ok'; pageText: string; }
type RequestState = RequestPending | RequestError | RequestSuccess;

interface State {
  currentPage: string;
  requests: { [page: string]: RequestState };
}
  • 무효한 상태(로딩 중이면서 에러)가 타입 수준에서 불가능
  • renderPagechangePage 구현이 자명해짐
  • 여러 요청이 동시에 진행되어도 각 요청이 독립적으로 관리됨

에어 프랑스 447편 교훈:

// 나쁜 설계: 두 조종사가 독립적으로 조작 → 평균을 내는 버그 발생
interface CockpitControls {
  leftSideStick: number;
  rightSideStick: number;
}

// 좋은 설계: 실제 에어버스처럼 기계적으로 연결된 단일 상태
interface CockpitControls {
  stickAngle: number; // getStickSetting 함수 자체가 필요 없어짐
}

기억할 것: 유효한 상태와 무효한 상태를 함께 표현하는 타입은 혼란과 버그를 야기한다. 길고 복잡하더라도 유효한 상태만 표현하는 타입을 선택하라.

Item 30: 입력에는 관대하게, 출력에는 엄격하게

Postel의 법칙(견고함의 원칙): 보내는 것에는 보수적으로, 받는 것에는 관대하게.

// 나쁜 예: 19가지 형태의 입력을 허용하지만 출력도 넓음
type LngLatBounds =
  | { northeast: LngLat; southwest: LngLat }
  | [LngLat, LngLat]
  | [number, number, number, number];

declare function viewportForBounds(bounds: LngLatBounds): CameraOptions;
// 반환 타입: center?: LngLat, zoom?: number, bearing?: number ...
// 모든 프로퍼티가 optional → 사용하기 매우 불편

좋은 설계 패턴:

// 입력용 느슨한 타입
type LngLatLike = LngLat | { lon: number; lat: number } | [number, number];
type LngLatBounds = { northeast: LngLatLike; southwest: LngLatLike } | ...;

// 출력용 정확한 타입 (완전히 정의된 Camera)
interface Camera { center: LngLat; zoom: number; bearing: number; pitch: number; }

declare function viewportForBounds(bounds: LngLatBounds): Camera; // 출력은 엄격
declare function setCamera(camera: CameraOptions): void; // 입력은 관대

배열 파라미터의 경우: 단순 이터레이션만 필요하다면 Array 대신 Iterable<T>를 사용하면 제너레이터 표현식도 받을 수 있다:

function sum(xs: Iterable<number>): number { ... }
const zeroToNine = range(10); // Generator
const fortyFive = sum(zeroToNine); // OK

기억할 것: 입력 타입은 출력 타입보다 넓어야 한다. 반환 타입에서 넓은 유니온이나 optional이 많으면 사용이 불편해진다. 반환용 정식 타입(canonical form)과 파라미터용 느슨한 타입(like suffix)을 구분하라.

Item 31: 문서에 타입 정보를 반복하지 말라

코드와 주석이 불일치하면 둘 다 틀린 것이다.

나쁜 예:

/**
 * Returns a string with the foreground color.
 * Takes zero or one arguments.
 */
function getForegroundColor(page?: string) {
  return page === 'login' ? { r: 127, g: 127, b: 127 } : { r: 0, g: 0, b: 0 };
  // 실제로는 string이 아닌 객체를 반환 → 주석이 틀림
}

좋은 예:

/** Get the foreground color for the application or a specific page. */
function getForegroundColor(page?: string): Color { ... }

변경 불가를 선언하는 주석 대신 readonly를 사용하라:

// 나쁜 예: 주석으로 "변경하지 않음" 선언 - 강제되지 않음
/** Does not modify nums. */
function sortNumerically(nums: string[]): string[] {
  return nums.sort((a, b) => Number(a) - Number(b)); // 실제로 in-place 정렬!
}

// 좋은 예: readonly로 TypeScript가 강제하게 함
function sortNumerically(nums: readonly string[]): string[] {
  return nums.toSorted((a, b) => Number(a) - Number(b)); // OK
}

변수명에 타입 정보를 넣지 마라:

  • ageNum 대신 age로, 타입은 타입 시스템이 관리
  • 단위가 불명확한 경우는 예외: timeMs, temperatureC처럼 단위를 포함하면 명확

Item 32: 타입 별칭에 null이나 undefined를 포함하지 말라

// 나쁜 예: User가 null일 수도 있다는 사실이 이름에서 보이지 않음
type User = { id: string; name: string } | null;
function getCommentsForUser(comments: Comment[], user: User) {
  return comments.filter(c => c.userId === user?.id); // user가 null인지 불명확
}

// 좋은 예: 함수 시그니처에서 명시
function getCommentsForUser(comments: Comment[], user: User | null) {
  return comments.filter(c => c.userId === user?.id);
}

// 불가피하게 nullable 타입 별칭이 필요하다면 이름을 명확하게
type NullableUser = { id: string; name: string } | null;
  • 타입 이름을 보면 그것이 무엇을 나타내는지 알 수 있어야 한다
  • User라는 이름은 “사용자”를 나타내야지, “사용자 또는 null”을 나타내면 안 된다
  • 이 규칙은 타입 별칭의 최상위 레벨에 관한 것이며, 객체 내부의 optional 필드나 null 값은 별개 주제다

Item 33: null 값을 타입의 경계로 밀어내라

strictNullChecks를 켜면 null/undefined 관계가 암묵적으로 얽혀 있을 때 코드가 복잡해진다.

나쁜 설계 - null 값이 내부에 얽혀 있는 경우:

// min이 null이면 max도 null이어야 함 - 하지만 타입에 표현되지 않음
function extent(nums: Iterable<number>) {
  let min, max; // 둘 다 undefined로 시작
  for (const num of nums) {
    if (!min) { min = num; max = num; }
    else {
      min = Math.min(min, num);
      max = Math.max(max, num); // Error: max가 undefined일 수도 있음
    }
  }
  return [min, max]; // (number | undefined)[] - 사용하기 불편
}

좋은 설계 - null을 경계로 밀어낸 경우:

function extent(nums: Iterable<number>): [number, number] | null {
  let minMax: [number, number] | null = null;
  for (const num of nums) {
    if (!minMax) {
      minMax = [num, num];
    } else {
      const [oldMin, oldMax] = minMax;
      minMax = [Math.min(num, oldMin), Math.max(num, oldMax)];
    }
  }
  return minMax;
}
// 클라이언트는 단일 null 체크로 처리 가능
const range = extent([0, 1, 2]);
if (range) {
  const [min, max] = range; // OK
}

클래스에서 null을 경계로 밀어내기:

// 나쁜 예: 생성자에서 null로 초기화 → 모든 메서드에서 null 체크 필요
class UserPosts {
  user: UserInfo | null;
  posts: Post[] | null;
  constructor() { this.user = null; this.posts = null; }
}

// 좋은 예: 데이터가 모두 준비된 후 생성
class UserPosts {
  constructor(public user: UserInfo, public posts: Post[]) {}
  static async init(userId: string): Promise<UserPosts> {
    const [user, posts] = await Promise.all([
      fetchUser(userId),
      fetchPostsForUser(userId),
    ]);
    return new UserPosts(user, posts);
  }
}

Item 34: 유니온을 갖는 인터페이스보다 인터페이스들의 유니온을 선호하라

프로퍼티가 유니온 타입을 가지고 있다면, 별도 인터페이스의 유니온으로 분리하는 것이 더 정확하다.

나쁜 예 - 불일치 조합이 가능:

interface Layer {
  layout: FillLayout | LineLayout | PointLayout;
  paint: FillPaint | LinePaint | PointPaint;
}
// FillLayout + LinePaint 같은 무효 조합이 가능

좋은 예 - 태그드 유니온으로 분리:

interface FillLayer { layout: FillLayout; paint: FillPaint; }
interface LineLayer { layout: LineLayout; paint: LinePaint; }
interface PointLayer { layout: PointLayout; paint: PointPaint; }
type Layer = FillLayer | LineLayer | PointLayer;

태그를 추가하면 더욱 강력해진다:

interface FillLayer { type: 'fill'; layout: FillLayout; paint: FillPaint; }
interface LineLayer { type: 'line'; layout: LineLayout; paint: LinePaint; }
type Layer = FillLayer | LineLayer;

function renderLayer(layer: Layer) {
  if (layer.type === 'fill') {
    // layer: FillLayer로 좁혀짐
  }
}

함께 존재하거나 함께 없는 옵셔널 필드도 동일 원칙을 적용:

// 나쁜 예: 관련성이 타입에 표현되지 않음
interface Person {
  name: string;
  placeOfBirth?: string; // 이 둘은 항상 함께 있거나 함께 없어야 함
  dateOfBirth?: Date;
}

// 좋은 예: 관련 필드를 하나의 객체로 묶어 명시
interface Person {
  name: string;
  birth?: { place: string; date: Date };
}

Item 35: string 타입보다 더 정확한 대안을 선호하라

string의 도메인은 매우 넓다. “Moby Dick” 전체 텍스트도 string이다. 더 좁은 타입이 적절한지 항상 물어봐야 한다.

나쁜 예 - “stringly typed” 코드:

interface Album {
  artist: string;
  title: string;
  releaseDate: string; // 'August 17th, 1959'처럼 잘못된 포맷도 가능
  recordingType: string; // 'Studio', 'LIVE' 등 잘못된 값도 가능
}

좋은 예 - 정확한 타입 사용:

type RecordingType = 'studio' | 'live';

interface Album {
  artist: string;
  title: string;
  releaseDate: Date; // 포맷 문제 해결
  recordingType: RecordingType; // 유효하지 않은 값 방지
}

string 대신 keyof T 사용:

// string 사용: any 타입의 문제와 유사
function pluck(records: any[], key: string): any[] {
  return records.map(r => r[key]);
}

// keyof T 사용: 타입 안전, 자동완성 지원
function pluck<T, K extends keyof T>(records: T[], key: K): T[K][] {
  return records.map(r => r[key]);
}

const dates = pluck(albums, 'releaseDate'); // Date[]로 정확히 추론
const bad = pluck(albums, 'recordingDate'); // Error: 유효하지 않은 키

기억할 것: stringly typed 코드를 피하라. 유한한 문자열 집합이라면 유니온 리터럴 타입을 사용하라. 객체의 키라면 string 대신 keyof T를 사용하라.

Item 36: 특수 값에는 별도의 타입을 사용하라

-1, 0, '' 같은 도메인 내 특수 값(in-domain special values)은 TypeScript가 일반 값과 구분하지 못한다.

나쁜 예 - indexOf-1 문제:

function splitAround<T>(vals: readonly T[], val: T): [T[], T[]] {
  const index = vals.indexOf(val);
  return [vals.slice(0, index), vals.slice(index + 1)]; // index가 -1이면 버그!
}
splitAround([1, 2, 3, 4, 5], 6); // [1,2,3,4], [1,2,3,4,5] - 이상한 결과

좋은 예 - null을 반환하는 래퍼 함수:

function safeIndexOf<T>(vals: readonly T[], val: T): number | null {
  const index = vals.indexOf(val);
  return index === -1 ? null : index;
}

function splitAround<T>(vals: readonly T[], val: T): [T[], T[]] {
  const index = safeIndexOf(vals, val);
  if (index === null) return [...vals, []]; // 명시적으로 처리 강제
  return [vals.slice(0, index), vals.slice(index + 1)]; // OK
}
  • priceDollars: -1 같은 특수 값을 사용하면 strictNullChecks를 끈 것과 유사한 효과 → 타입 체커가 일반 값과 구분하지 못한다
  • 의미가 명확하지 않은 경우에는 null/undefined 대신 태그드 유니온을 고려하라

Item 37: 선택적 프로퍼티 사용을 제한하라

optional 프로퍼티는 편의를 위해 추가하기 쉽지만, 숨겨진 비용이 있다.

interface FormattedValue {
  value: number;
  units: string;
  unitSystem?: UnitSystem; // optional 추가 - 기본값은 imperial
}

optional 프로퍼티의 문제점:

  • 기본값이 무엇인지 알 수 없으면 실수 유발 (metric이 기본이라고 오해)
  • API 사용자가 명시적으로 지정하지 않으면 의도와 다른 동작 발생
  • unitSystem이 있어야 하는 경우와 없어도 되는 경우가 혼재되어 타입이 너무 허용적

대안 설계:

// 방법 1: 필수로 만들기 (기존 코드 수정 필요)
interface FormattedValue {
  value: number;
  units: string;
  unitSystem: UnitSystem; // 항상 명시 강제
}

// 방법 2: 태그드 유니온으로 별도 표현
type FormattedValue =
  | { value: number; units: string; unitSystem: 'metric' }
  | { value: number; units: string; unitSystem: 'imperial' };
  • optional 프로퍼티는 “있어도 되고 없어도 된다”는 의미이지, “기본값이 있다”는 의미가 아니다
  • 관련 있는 프로퍼티 여러 개가 동시에 optional이라면 Item 34의 유니온 인터페이스 패턴을 고려하라

Item 38: 같은 타입의 반복 파라미터를 피하라

// 나쁜 예: 같은 타입 파라미터 반복 → 순서 실수를 TypeScript가 잡지 못함
function recordRelease(title: string, date: string) { ... }
recordRelease(kindOfBlue.releaseDate, kindOfBlue.title); // 순서 바뀌어도 에러 없음

// 좋은 예: 객체로 묶어서 전달
function recordRelease(album: { title: string; date: string }) { ... }
  • Item 35의 예시에서 title: string, date: string은 둘 다 string이라 순서 오류를 타입 체커가 잡지 못한다
  • 함수 파라미터에서 같은 타입이 여러 개 반복되면 항상 객체로 묶는 것을 고려하라

Item 39: 타입의 차이를 모델링하기보다 단일 타입으로 통합하라

서로 약간 다른 두 타입을 별도로 유지하면 변환 코드와 인지 부하가 증가한다.

예시 - DB 모델과 API 응답 타입의 차이:

// 나쁜 예: 거의 동일한데 별도로 유지
interface UserDB { firstName: string; lastName: string; }
interface UserAPI { first_name: string; last_name: string; } // snake_case

// 두 타입 간 변환 코드가 항상 필요하고 싱크가 맞아야 함
// 좋은 예: 하나의 타입으로 통합
interface User { firstName: string; lastName: string; }
// DB/API 레이어에서 변환 처리

주의사항:

  • 타입이 실제로 다른 것(태그드 유니온의 각 케이스 등)은 통합하지 말 것
  • 외부 시스템(DB, 외부 API)이 통제권 밖이라면 변환을 명시적으로 모델링하는 것이 나을 수 있다

Item 40: 부정확한 타입보다 덜 정확한 타입을 선호하라

타입 선언의 정확도를 높이려다 틀린 타입을 만들면, 타입이 없는 것보다 더 나쁘다.

예시 - GeoJSON 좌표:

// 시도: 더 정확하게
type GeoPosition = [number, number]; // latitude, longitude만 허용
interface Point { type: 'Point'; coordinates: GeoPosition; }

// 문제: GeoJSON 스펙은 세 번째 요소(고도)도 허용
// → 유효한 GeoJSON이 에러를 낸다 → 사용자가 any로 우회

Mapbox 표현식 타입 정확도의 딜레마:

// 너무 느슨 - 잘못된 표현도 허용
type Expression1 = any;
type Expression2 = number | string | any[];

// 적당히 정확 - 함수명 검사 추가
type FnName = 'case' | 'rgb' | ...;
type CallExpression = [FnName, ...any[]];
type Expression3 = number | string | CallExpression;

// 너무 정밀하게 하려다 오히려 인터페이스가 복잡해지고 에러 메시지도 나빠짐

핵심 원칙:

  • “언캐니 밸리(uncanny valley)“를 피하라: 복잡하지만 부정확한 타입은 단순하고 덜 정확한 타입보다 나쁘다
  • 정확히 모델링할 수 없다면 부정확하게 모델링하지 말고, anyunknown으로 한계를 인정하라
  • 타입이 정밀해질수록 에러 메시지와 자동완성도 함께 검증하라

Item 41: 문제 도메인의 언어로 타입을 명명하라

“컴퓨터 과학에서 두 가지 어려운 문제가 있다: 캐시 무효화와 이름 짓기.”

잘 선택된 이름은 추상화 수준을 높이고, 잘못된 이름은 잘못된 멘탈 모델을 심어준다.

나쁜 예:

interface Animal {
  name: string;       // 어떤 이름? 학명? 일반명?
  endangered: boolean; // 멸종이면 어떻게? extinct는 해당 안 됨?
  habitat: string;    // 너무 모호
}
const leopard: Animal = {
  name: 'Snow Leopard', // 변수명은 leopard인데 값은 'Snow Leopard'
  endangered: false,    // 실제로 VU(취약) 등급
  habitat: 'tundra',
};

좋은 예:

type ConservationStatus = 'EX' | 'EW' | 'CR' | 'EN' | 'VU' | 'NT' | 'LC'; // IUCN 표준
type KoppenClimate = 'Af' | 'Am' | 'As' | ... ; // 쾨펜 기후 분류

interface Animal {
  commonName: string;         // 구체적
  genus: string;              // 분류학적 명칭
  species: string;
  status: ConservationStatus; // 표준 용어 사용
  climates: KoppenClimate[];  // 표준 분류 체계
}
const snowLeopard: Animal = {
  commonName: 'Snow Leopard',
  genus: 'Panthera',
  species: 'Uncia',
  status: 'VU',
  climates: ['ET', 'EF', 'Dfd'],
};

좋은 타입 명명 원칙:

  • 동일한 개념에 다른 이름을 사용하지 말라 (불필요한 구분을 만들지 말라)
  • 기존 용어가 있다면 그것을 사용하라 (도메인 전문가와 같은 언어)
  • 모호한 이름(name, data, value, info, thing)을 피하라. 더 구체적인 이름을 찾아라
  • IFoo, CFoo 같은 언어 특성을 이름에 넣지 말라 (TypeScript는 구조적 타이핑을 사용하므로 불필요)

Chapter 5: Unsoundness and the any Type

TypeScript의 타입 시스템은 선택적(optional)이고 점진적(gradual)이다. 기존 JavaScript 코드베이스를 단계적으로 마이그레이션할 수 있게 해주는 핵심이 바로 any 타입이다. any는 강력하지만 남용하기 쉬우며, 타입 검사를 사실상 비활성화시킨다. 이 챕터는 any의 단점을 최소화하면서 이점을 유지하는 방법을 다룬다.

Item 43: any의 범위를 최대한 좁게 유지하라

핵심 원칙: any를 사용해야 한다면, 그 영향 범위를 시간적·공간적으로 최소화하라.

시간적 범위 제한 예시:

declare function getPizza(): Pizza;
function eatSalad(salad: Salad): void { /* ... */ }

// 나쁜 방법: pizza 변수 전체가 any가 됨
function eatDinner1() {
  const pizza: any = getPizza(); // 이후 pizza.slice도 타입 검사 없음
  eatSalad(pizza);
  pizza.slice(); // 완전히 미검사!
}

// 좋은 방법: any 범위가 단일 표현식에 한정됨
function eatDinner2() {
  const pizza = getPizza();
  eatSalad(pizza as any); // 이 줄에만 영향
  pizza.slice(); // 여전히 Pizza 타입으로 타입 검사됨
}

any 반환은 전염성을 가짐:

function eatDinner1(): any {
  const pizza: any = getPizza();
  return pizza; // any가 탈출!
}

function spiceItUp() {
  const pizza = eatDinner1(); // pizza: any → 이후 모든 호출 미검사
  pizza.addRedPepperFlakes(); // 오류 없이 통과
}
  • any를 반환하는 함수는 코드베이스 전체에 오염을 일으킨다
  • 반환 타입을 명시적으로 annotate하면 any 탈출을 방지할 수 있다

공간적 범위 제한 (객체의 특정 프로퍼티만):

// 나쁜 방법: 전체 객체가 any
const config: Config = {
  a: 1,
  b: 2,
  c: { key: value },
} as any; // a, b 프로퍼티 검사도 비활성화

// 좋은 방법: 문제 프로퍼티만 any
const config: Config = {
  a: 1,          // 검사됨
  b: 2,          // 검사됨
  c: { key: value } as any,
};

ts-expect-errorts-ignore:

  • any 대신 // @ts-expect-error// @ts-ignore로 특정 줄 에러를 억제할 수 있음
  • ts-expect-error가 더 선호됨: 에러가 사라지면 TypeScript가 directive 제거를 알려줌
  • 단, 같은 줄에 두 번째 에러가 생겨도 알 수 없다는 한계 존재

핵심 요약:

  • any는 가능한 한 좁은 범위로 제한하라
  • 함수에서 any 타입을 반환하지 마라 (전염성)
  • 객체 전체가 아닌 개별 프로퍼티에 as any를 사용하라

Item 44: any보다 더 정확한 변형을 선호하라

any는 JavaScript에서 표현 가능한 모든 값을 포함한다. any를 사용하기 전에 더 구체적인 타입이 가능한지 고려하라.

배열 타입:

// 나쁨: any → 함수 본문 타입 검사 없음, 반환 타입도 any
function getLengthBad(array: any): number {
  return array.length;
}

// 좋음: any[] → 배열임을 보장, 반환 타입은 number로 추론
function getLength(array: any[]): number {
  return array.length;
}

getLength(123);   // Error: Argument of type number is not assignable to type any[]
getLengthBad(123); // No error, returns undefined at runtime

객체 타입:

// Record<string, any>: 값에 접근 가능
function hasKeyEndingWithZ(o: Record<string, any>): boolean {
  for (const key in o) {
    if (key.endsWith('z')) return true;
  }
  return false;
}

// object: 키 열거는 되지만 값 접근 불가
function hasKeyEndingWithZ(o: object): boolean {
  for (const key in o) {
    if (key.endsWith('z')) console.log(key, o[key]); // Error: 인덱스 시그니처 없음
  }
  return false;
}

함수 타입:

type Fn0 = () => any;           // 파라미터 없는 함수
type Fn1 = (arg: any) => any;   // 파라미터 1개
type FnN = (...args: any[]) => any; // 가변 파라미터 (Function과 동일)

// any[] vs any in rest params
const numArgsBetter = (...args: any[]) => args.length; // 반환 타입: number
const numArgsBad = (...args: any) => args.length;      // 반환 타입: any

핵심 요약:

  • any를 쓸 때 모든 JavaScript 값이 실제로 허용되어야 하는지 묻자
  • 데이터를 더 정확히 모델링하는 any[], {[id: string]: any}, () => any 같은 형태를 선호하라

Item 45: 안전하지 않은 타입 단언은 잘 타입된 함수 내부에 숨겨라

원칙: 안전한 구현과 원하는 타입 시그니처 중 하나를 선택해야 한다면, 타입 시그니처를 선택하라. 구현은 함수 내부에 숨겨지지만, 타입 시그니처는 공개 API다.

나쁜 패턴: 타입 시그니처를 타협하는 경우:

// 반환 타입을 unknown으로 바꾸면 호출부마다 단언이 필요
export async function fetchPeak(peakId: string): Promise<unknown> {
  return checkedFetchJSON(`/api/mountain-peaks/${peakId}`);
}

// 호출 코드가 지저분해짐
const peaks = await Promise.all(sevenPeaks.map(fetchPeak as MountainPeak));

좋은 패턴: 단언을 함수 내부에 숨기기:

export async function fetchPeak(peakId: string): Promise<MountainPeak> {
  return checkedFetchJSON(
    `/api/mountain-peaks/${peakId}`
  ) as Promise<MountainPeak>; // 단언이 구현부에 숨겨짐
}

// 호출 코드가 깔끔해짐
const peaks = await Promise.all(sevenPeaks.map(fetchPeak)); // 단언 불필요

단언 안전성을 높이는 방법 (부분 shape 검증):

export async function fetchPeak(peakId: string): Promise<MountainPeak> {
  const maybePeak = await checkedFetchJSON(`/api/mountain-peaks/${peakId}`);
  if (
    !maybePeak || typeof maybePeak !== 'object' ||
    !('firstAscentYear' in maybePeak)
  ) {
    throw new Error(`Invalid mountain peak: ${JSON.stringify(maybePeak)}`);
  }
  return maybePeak as Promise<MountainPeak>;
}

올바른 타입 단언의 예 (단언이 불가피한 경우):

// 나쁜 방법: b의 타입을 any로 바꿔 외부로 노출
function shallowObjectEqualBad(a: object, b: any): boolean { /* ... */ }
shallowObjectEqual({x: 1}, null); // 런타임 crash

// 좋은 방법: any를 구현 내부에 한정
function shallowObjectEqualGood(a: object, b: object): boolean {
  for (const [k, aVal] of Object.entries(a)) {
    if (!(k in b) || aVal !== (b as any)[k]) { // k in b를 확인 후 any 사용
      return false;
    }
  }
  return Object.keys(a).length === Object.keys(b).length;
}

핵심 요약:

  • 단언이 필요하다면 올바른 시그니처를 가진 함수 내부에 숨겨라
  • 타입 에러를 해결하려고 함수의 타입 시그니처를 타협하지 마라
  • 단언의 타당성을 설명하는 주석을 달고 단위 테스트를 반드시 작성하라

Item 46: 알 수 없는 타입의 값에는 any 대신 unknown을 사용하라

any의 두 가지 성질 (문제의 원인):

성질 설명
모든 타입이 any에 할당 가능 any는 최상위 타입 (top type)
any가 모든 타입에 할당 가능 any는 최하위 타입 (bottom type)

이 두 가지를 동시에 만족하는 집합은 존재할 수 없으므로 any는 타입 시스템 밖에 있다.

unknown: 타입 안전한 대안:

// 나쁨: any 반환 → 타입 오염 전파
function parseYAML(yaml: string): any { /* ... */ }
const book = parseYAML(`...`);
book.title;  // No error (런타임에 undefined)
book.read(); // No error (런타임에 crash)

// 좋음: unknown 반환 → 사용 전 narrowing 강제
function safeParseYAML(yaml: string): unknown { /* ... */ }
const book = safeParseYAML(`...`);
book.title; // Error: book is of type unknown

unknown 타입 narrowing 방법:

// 1. 타입 단언
const book = safeParseYAML(`...`) as Book;

// 2. instanceof 검사
function processValue(value: unknown) {
  if (value instanceof Date) {
    value; // type: Date
  }
}

// 3. 사용자 정의 타입 가드
function isBook(value: unknown): value is Book {
  return (
    typeof value === 'object' && value !== null &&
    'name' in value && 'author' in value
  );
}

잘못된 패턴: return-only generic:

// 나쁨: 타입 단언처럼 동작하지만 더 위험하게 보임
function safeParseYAML<T>(yaml: string): T { return parseYAML(yaml); }
const book = safeParseYAML<Book>(`...`); // 가짜 타입 안전성

// 좋음: unknown을 반환해 사용자가 명시적 단언 수행
function safeParseYAML(yaml: string): unknown { return parseYAML(yaml); }

이중 단언에서 any 대신 unknown 사용:

let barAny = foo as any as Bar;  // any가 코드 리뷰에서 경고를 유발
let barUnk = foo as unknown as Bar; // 기능적으로 동일하지만 덜 충격적

{}, object, unknown 비교:

타입 포함하는 값
{} nullundefined를 제외한 모든 값
Object (대문자) {}와 거의 동일, 원시값 포함
object (소문자) 비원시 타입 (객체, 배열, 함수)
unknown 모든 값 (top type)

핵심 요약:

  • unknown은 타입 안전한 any의 대안이다
  • 값이 존재하지만 타입을 모를 때 unknown을 사용하라
  • return-only 타입 파라미터를 피하라 (가짜 안전감을 줌)

Item 47: 몽키 패칭에는 타입 안전한 접근 방식을 사용하라

JavaScript의 객체와 클래스는 열려 있어 임의의 프로퍼티를 추가할 수 있다. TypeScript는 빌트인 타입에 추가된 프로퍼티를 알지 못해 에러를 발생시킨다.

나쁜 방법: as any 사용:

(document as any).monkey = 'Tamarin'; // 타입 안전성 없음
(document as any).monky = 'Tamarin';  // 오타도 통과

좋은 방법 1: 인터페이스 증강(Augmentation):

declare global {
  interface Window {
    /** 현재 로그인된 유저 */
    user: User | undefined;
  }
}
// 이후 window.user를 타입 안전하게 사용 가능
window.user = user; // OK
window.user.name;   // Object is possibly undefined → 강제 체크

좋은 방법 2: 커스텀 인터페이스로 타입 단언:

type MyWindow = typeof window & {
  /** 현재 로그인된 유저 */
  user: User | undefined;
};

(window as MyWindow).user = user; // 전역 타입 오염 없이 사용 가능

두 방법의 트레이드오프:

방법 장점 단점
인터페이스 증강 사용 편의성 전역 스코프에 영향, 페이지별 제어 불가
커스텀 타입 단언 스코프 관리 용이 사용 시마다 단언 필요

핵심 요약:

  • 가능하면 전역이나 DOM에 데이터를 저장하지 말고 구조화된 코드를 사용하라
  • 꼭 필요하다면 augmentation 또는 커스텀 인터페이스 단언을 사용하라
  • 런타임에 설정되는 전역 변수는 undefined를 포함시켜 race condition을 반영하라

Item 48: 건전성 함정(Soundness Traps)을 피하라

건전성(Soundness)이란: 모든 심볼의 정적 타입이 런타임 값과 항상 일치하는 성질. TypeScript는 의도적으로 완전한 건전성을 포기하고 편의성과 표현력을 선택했다.

주요 비건전성 소스와 대응 방법:

1. any 타입

function logNumber(x: number) { console.log(x.toFixed(1)); }
const num: any = 'forty two';
logNumber(num); // 타입 에러 없음, 런타임 crash
  • 대응: any 사용 최소화, unknown으로 대체

2. 타입 단언 (as)

const hour: number | null = new Date().getHours() ?? null;
logNumber(hour as number); // null이면 런타임 crash
// 대응: 조건문으로 narrowing
if (hour !== null) logNumber(hour);

3. 객체/배열 룩업 (범위 미검사)

const xs = [0, 1, 2];
const x = xs; // 타입: number, 실제: undefined
console.log(x.toFixed(1)); // 런타임 crash

// 대응: noUncheckedIndexedAccess 옵션 활성화
// 또는 값 타입에 명시적으로 undefined 추가
const xs: (number | undefined)[] = [0, 1, 2];

4. 부정확한 타입 선언

  • 서드파티 라이브러리의 타입 선언이 잘못된 경우 (예: 역사적 이유로 잘못 타입된 Object.assign)
  • 대응: DefinitelyTyped에서 버그 수정, 또는 augmentation으로 우회

5. 클래스 계층에서의 이변성(Bivariance)

class Parent { foo(x: number | string): void {} }
class Child extends Parent {
  foo(x: number): void { // 파라미터 타입을 좁혀도 에러 없음 (메서드는 이변성)
    console.log(x.toFixed());
  }
}
const p: Parent = new Child();
p.foo('string'); // 타입 에러 없음, 런타임 crash
  • 대응: 자식 클래스 메서드 시그니처를 부모와 정확히 일치시켜라

6. 배열/객체 변이(Mutation)

function addFoxOrHen(animals: Animal[]) {
  animals.push(Math.random() > 0.5 ? new Fox() : new Hen());
}
const henhouse: Hen[] = [new Hen()];
addFoxOrHen(henhouse); // 타입 에러 없음, henhouse에 Fox가 들어감

// 대응: 파라미터를 readonly로 선언
function addFoxOrHen(animals: readonly Animal[]) { /* ... */ }

7. 함수 콜백이 refinement를 무효화

interface FunFact { fact: string; author?: string; }
function processFact(fact: FunFact, processor: (f: FunFact) => void) {
  if (fact.author) {
    processor(fact); // processor가 fact.author를 지울 수 있음
    console.log(fact.author.blink()); // 런타임 crash 가능
  }
}
  • 대응: 콜백에 Readonly<FunFact>를 전달해 변이 방지

8. 옵셔널 프로퍼티와 할당 가능성

interface Person { name: string; }
interface PossiblyAgedPerson extends Person { age?: number; }
const p1 = { name: 'Serena', age: '42 years' }; // age가 string!
const p2: Person = p1; // 구조적 타이핑으로 허용
const p3: PossiblyAgedPerson = p2; // age?: number인데 실제로는 string
console.log(p3.age?.toFixed(1)); // 런타임 crash
  • 대응: 옵셔널 프로퍼티 추가 시 타입 충돌 가능성을 인지하라

핵심 요약:

  • 비건전성은 언어의 결함이 아니라 편의성·표현력·안전성 스펙트럼에서의 의도적 선택이다
  • any, 타입 단언, 배열/객체 룩업, 부정확한 타입 선언에서 비건전성이 발생함을 인지하라
  • 함수 파라미터를 변이하지 말고, 불필요하다면 readonly로 선언하라

Item 49: any 타입 커버리지를 추적해 타입 안전성 회귀를 방지하라

noImplicitAny를 설정해도 any는 두 경로로 코드에 유입된다:

  • 명시적 any 타입: 직접 작성한 any
  • 서드파티 타입 선언: declare module 'my-module' 같은 모듈 전체 any 선언

type-coverage 도구 활용:

# 프로젝트 전체 any 비율 확인
npx type-coverage
# 9985 / 10117 98.69%

# any가 있는 위치 상세 출력
npx type-coverage --detail
# path/to/code.ts:110:6 getColumnInfo
# path/to/module.ts:71:10 pt2

점수 향상 방법:

  • 좁은 범위의 any 사용 → any 심볼 수 감소
  • any 대신 any[] 같은 더 정확한 형태 사용 → any 심볼 수 감소
  • 시간이 지나 불필요해진 any를 정기적으로 제거

CI에 추가:

  • type-coverage를 CI 파이프라인에 통합하면 any 비율 갑작스러운 하락을 즉시 감지 가능
  • TypeScript Language Service 플러그인으로 설정하면 에디터에서 any 타입을 실시간으로 하이라이트

핵심 요약:

  • noImplicitAny가 설정되어 있어도 any는 명시적 사용 또는 서드파티 선언으로 유입됨
  • type-coverage 같은 도구로 타입 커버리지를 추적해 회귀를 방지하라

Chapter 6: Generics and Type-Level Programming

TypeScript의 타입 시스템은 Turing Complete이며, 제네릭 타입을 통해 타입 레벨의 독립적인 프로그래밍 언어를 형성한다. 값 레벨에서 프로그래밍하는 것처럼 타입 레벨에서도 프로그래밍할 수 있지만, 직관적이지 않을 수 있으므로 과도한 사용은 피해야 한다.

Item 50: 제네릭을 타입 간의 함수로 생각하라

핵심 비유: 값 레벨에서 함수가 반복 코드를 제거하듯, 타입 레벨에서는 제네릭 타입이 그 역할을 한다.

// 내장 Partial 직접 구현
type MyPartial<T> = { [K in keyof T]?: T[K] };

interface Person { name: string; age: number; }
type MyPartPerson = MyPartial<Person>;
// → { name?: string; age?: number; }

extends로 타입 파라미터 제약:

// 잘못된 구현 (제약 없음)
type MyPick<T, K> = { [P in K]: T[P] }; // Error: K는 PropertyKey여야 함

// 올바른 구현 (제약 추가)
type MyPick<T extends object, K extends keyof T> = { [P in K]: T[P] };

type AgeOnly = MyPick<Person, 'age'>;         // OK
type Bad = MyPick<Person, 'firstName'>;       // Error: 'firstName'은 keyof Person에 없음
type Flip = MyPick<'age', Person>;            // Error: string은 object에 할당 불가

제네릭 함수와 클래스:

// 제네릭 함수: TypeScript가 호출 시 타입을 추론
function pick<T extends object, K extends keyof T>(obj: T, ...keys: K[]): Pick<T, K> {
  // ...
}
const p: Person = { name: 'Matilda', age: 5.5 };
const age = pick(p, 'age'); // Pick<Person, 'age'>로 추론

// 제네릭 클래스: 생성 시 타입 파라미터 바인딩
class Box<T> {
  constructor(public value: T) {}
}
const dateBox = new Box(new Date()); // Box<Date>로 추론

TSDoc으로 타입 파라미터 문서화:

/**
 * 다른 객체 타입의 일부 프로퍼티로 새 객체 타입을 구성합니다.
 * @template T 원본 객체 타입
 * @template K 선택할 키, 보통 문자열 리터럴 타입의 유니온
 */
type MyPick<T extends object, K extends keyof T> = { [P in K]: T[P] };

핵심 요약:

  • 제네릭 타입을 타입 간의 함수로 생각하라
  • extends로 타입 파라미터 도메인을 제약하라 (함수 파라미터에 타입 annotation하는 것처럼)
  • 코드 가독성을 높이는 의미 있는 타입 파라미터 이름을 선택하고 TSDoc을 작성하라
  • 제네릭 함수와 클래스는 타입 추론을 가능하게 하는 연관된 제네릭 타입을 개념적으로 정의한다

Item 51: 불필요한 타입 파라미터를 피하라

제네릭의 황금 규칙: “타입 파라미터는 두 번 이상 등장해야 한다”

타입 파라미터는 여러 값의 타입을 연관짓는 용도다. 함수 시그니처에서 한 번만 사용된다면 아무것도 연관짓지 않는 것이다.

좋은 제네릭 (T가 2번 등장):

function identity<T>(arg: T): T { return arg; } // 입력·출력 타입을 연관

나쁜 제네릭 (A, B가 1번만 등장):

function third<A, B, C>(a: A, b: B, c: C): C { return c; }
// 개선: A와 B 제거
function third<C>(a: unknown, b: unknown, c: C): C { return c; }

Return-only Generic의 위험성:

// 나쁨: 타입 단언처럼 동작하지만 타입 안전한 척
declare function parseYAML<T>(input: string): T;
const w: Weight = parseYAML(`...`); // 어떤 타입이든 OK → 가짜 안전성

// 좋음: unknown 반환 → 명시적 단언 강제
declare function parseYAML(input: string): unknown;
const w = parseYAML(`...`) as Weight; // 단언이 명시적

불필요한 제네릭 리팩터링 예시:

// 나쁨: K가 1번만 등장
function printProperty<T, K extends keyof T>(obj: T, key: K): void {
  console.log(obj[key]);
}

// 좋음: K를 파라미터 타입으로 이동
function printProperty<T>(obj: T, key: keyof T): void {
  console.log(obj[key]);
}

// 주의: 아래 함수는 K가 반환 타입에도 등장하므로 좋은 제네릭
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]; // 반환 타입 T[K] → K가 2번 등장
}

클래스에서의 적용:

// 나쁨: T가 메서드에만 필요 → 클래스에 두면 추론 불가
class Joiner<T extends string | number> {
  join(els: T[]): string { return els.map(el => String(el)).join(', '); }
}

// 좋음: T를 메서드로 이동
class Joiner {
  join<T extends string | number>(els: T[]): string {
    return els.map(el => String(el)).join(', ');
  }
}

// 더 좋음: 클래스 자체가 불필요
function join(els: (string | number)[]): string {
  return els.map(el => String(el)).join(', ');
}

핵심 요약:

  • 타입 파라미터는 관계를 설정하므로 반드시 두 곳 이상에 등장해야 한다
  • Return-only generic은 unknown 타입으로 대체하라
  • 제네릭이 필요하지 않다면 사용하지 마라 (제네릭의 제1 규칙: 쓰지 마라)

Item 52: 오버로드 시그니처보다 조건부 타입을 선호하라

문제: double 함수는 string을 받으면 string을, number를 받으면 number를 반환한다.

// 방법 1: 유니온 (너무 넓음)
declare function double(x: string | number): string | number;
const num = double(12); // string | number (부정확)

// 방법 2: 오버로드 (더 정확하지만 유니온 입력 불가)
declare function double(x: number): number;
declare function double(x: string): string;
function f(x: string | number) {
  return double(x); // Error: string | number는 각 오버로드에 맞지 않음
}

// 방법 3: 조건부 타입 (가장 정확)
declare function double<T extends string | number>(
  x: T
): T extends string ? string : number;
const num = double(12);  // number
const str = double('x'); // string
function f(x: string | number) {
  return double(x); // string | number (유니온도 정상 처리)
}

왜 조건부 타입이 더 나은가: 조건부 타입은 유니온에 분배되기 때문에 Tstring | number일 때 string | number로 올바르게 해석된다. 오버로드는 각 시그니처를 독립적으로 처리하므로 유니온을 처리할 수 없다.

조건부 타입 함수 구현 패턴:

// 단일 오버로드로 내부 구현 타입을 분리
function double<T extends string | number>(x: T): T extends string ? string : number;
function double(x: string | number): string | number {
  return typeof x === 'string' ? x + x : x + x;
}

핵심 요약:

  • 오버로드보다 조건부 타입을 선호하라
  • 조건부 타입은 유니온에 분배되어 추가 오버로드 없이 유니온 타입을 처리한다
  • 유니온 케이스가 불가능하거나 함수가 완전히 다른 두 함수인 경우에만 오버로드를 유지하라

Item 53: 조건부 타입에서 유니온 분배를 제어하는 방법을 알아라

유니온 분배의 기본 동작:

type IsString<T> = T extends string ? 'yes' : 'no';

type A = IsString<string>;      // 'yes'
type B = IsString<number>;      // 'no'
type C = IsString<string | number>; // 'yes' | 'no' (분배됨)

분배가 원하지 않는 경우: Comparable<T>가 유니온에 분배되면 교차가 아닌 유니온을 만들어 잘못된 타입 허용

type Comparable<T> = T extends Date ? Date | number :
                     T extends number ? number :
                     T extends string ? string : never;

let dateOrStr: Date | string = /* ... */;
isLessThan(dateOrStr, 'B'); // 허용됨 (원하지 않는 동작)

분배 방지: 조건을 1-tuple로 감싸기:

// [T] 로 감싸면 T가 bare type이 아니므로 분배 없음
type Comparable<T> = [T] extends [Date] ? Date | number :
                     [T] extends [number] ? number :
                     [T] extends [string] ? string : never;

isLessThan(dateOrStr, 'B'); // Error (분배 방지 후 올바른 동작)

분배를 강제로 활성화하는 방법: 분배가 일어나지 않는 조건부 타입에 분배를 추가

// N extends number 조건이 있지만 분배가 안 됨
type NTuple<T, N extends number> = NTupleHelp<T, N, []>;

// N extends number를 추가해 분배 활성화
type NTuple<T, N extends number> = N extends number ? NTupleHelp<T, N, never> : never;
// 이제 NTuple<bigint, 2 | 3> → [bigint, bigint] | [bigint, bigint, bigint] (정상)

booleannever에서의 특수 동작:

// boolean은 내부적으로 true | false로 처리되어 분배됨
type CelebrateIfTrue<V> = V extends true ? 'Huzzah!' : never;
type Surprise = CelebrateIfTrue<boolean>; // 'Huzzah!' (true 케이스만 분배)

// never는 빈 유니온 → 분배 시 아무것도 없어 never 반환
type N = AllowIn<never>; // never (예상과 다를 수 있음)

// 방지하려면 역시 1-tuple로 감싸기
type CelebrateIfTrue<V> = [V] extends [true] ? 'Huzzah!' : never;
type Surprise = CelebrateIfTrue<boolean>; // never

핵심 요약:

  • 조건부 타입이 유니온에 분배되기를 원하는지 항상 생각하라
  • 분배를 막으려면 조건을 [T]로 감싸라
  • booleannever의 특수한 분배 동작에 주의하라

Item 54: DSL과 문자열 간의 관계를 모델링하는 데 템플릿 리터럴 타입을 사용하라

기본 개념: 템플릿 리터럴 타입은 문자열 리터럴 타입과 JavaScript의 템플릿 리터럴 문법을 결합한다.

type PseudoString = `pseudo${string}`;
const science: PseudoString = 'pseudoscience'; // OK
const physics: PseudoString = 'physics';       // Error

DOM의 data-* 속성 모델링:

interface Checkbox {
  id: string;
  checked: boolean;
  [key: `data-${string}`]: unknown; // data- 접두사 속성만 허용
}
const check: Checkbox = {
  id: 'subscribe',
  checked: true,
  'data-listIds': 'all-the-lists', // OK
  'listIds': 'all-the-lists',      // Error
};

카멜케이스 변환 (objectToCamel):

// 한 번의 언더스코어 변환
type ToCamelOnce<S extends string> =
  S extends `${infer Head}_${infer Tail}`
    ? `${Head}${Capitalize<Tail>}`
    : S;

// 재귀적으로 모든 언더스코어 처리
type ToCamel<S extends string> =
  S extends `${infer Head}_${infer Tail}`
    ? `${Head}${Capitalize<ToCamel<Tail>>}`
    : S;

// 객체 키 전체에 적용하는 mapped type
type ObjectToCamel<T extends object> = {
  [K in keyof T as ToCamel<K & string>]: T[K];
};

유니온과 결합:

type Color = 'red' | 'green' | 'blue';
type Size = 'small' | 'large';
type ColorSize = `${Color}-${Size}`;
// 'red-small' | 'red-large' | 'green-small' | 'green-large' | 'blue-small' | 'blue-large'

querySelector 타입 정밀화 예시:

declare function querySelector<TagName extends keyof HTMLElementTagNameMap>(
  selector: TagName | `${string} ${TagName}` | `${TagName}${string}`
): HTMLElementTagNameMap[TagName] | null;

const img = document.querySelector('img.spectacular-sunset');
// HTMLImageElement | null

핵심 요약:

  • 템플릿 리터럴 타입은 문자열의 구조와 패턴을 타입 레벨에서 표현한다
  • DSL(CSS 선택자, camelCase 변환 등) 파싱에 특히 유용하다
  • infer와 조건부 타입과 결합해 강력한 문자열 타입 변환이 가능하다

Item 55: 타입을 테스트하라

왜 타입 테스트가 필요한가: 런타임 동작은 테스트하면서 타입 선언은 테스트하지 않는 경우가 많다. 잘못된 타입 선언은 잘못된 구현만큼 위험하다.

단순 할당 테스트의 한계:

const lengths: number[] = map(['john', 'paul'], name => name.length);
// 타입 검사는 하지만 정확한 타입인지는 확인 못함

assertType 헬퍼의 한계 (할당 가능성 vs 동등성):

declare function assertType<T>(x: T): void;

// 동등성이 아닌 할당 가능성만 검사
const add = (a: number, b: number) => a + b;
assertType<(a: number, b: number) => number>(add); // OK

const double = (x: number) => 2 * x;
assertType<(a: number, b: number) => number>(double); // OK!? (파라미터 수가 달라도 통과)

권장 도구: expect-type / vitest:

import { expectTypeOf } from 'expect-type';

const beatles = ['john', 'paul', 'george', 'ringo'];
expectTypeOf(map(beatles, name => name.length)).toEqualTypeOf<number[]>();
expectTypeOf(map(beatles, name => name.length)).toMatchTypeOf<number[]>();

콜백 타입 및 this 테스트:

const beatles = ['john', 'paul', 'george', 'ringo'];
expectTypeOf(
  map(beatles, function(name, i, array) {
    expectTypeOf(name).toEqualTypeOf<string>();
    expectTypeOf(i).toEqualTypeOf<number>();
    expectTypeOf(array).toEqualTypeOf<string[]>();
    expectTypeOf(this).toEqualTypeOf<string>(); // this 타입도 테스트
    return name.length;
  })
).toEqualTypeOf<number[]>();

타입 표시(display) 테스트: 구조가 아닌 표시 방식을 테스트하려면 eslint-plugin-expect-type을 사용한다.

도구 선택 가이드:

상황 권장 도구
DefinitelyTyped 기여 dtslint
일반 프로젝트 vitest + expect-type 또는 tsd
타입 표시 테스트 eslint-plugin-expect-type

핵심 요약:

  • 타입을 테스트할 때 할당 가능성과 동등성의 차이를 인식하라 (함수 타입에서 특히 중요)
  • 콜백 파라미터 추론 타입과 this 타입을 테스트하라
  • 자체 타입 테스트 코드를 만들지 말고 표준 도구를 사용하라

Item 56: 타입 표시 방식에 주의를 기울여라

동일한 타입도 다양한 방식으로 표시될 수 있다:

type PartComment = PartiallyPartial<BlogComment, 'title'>;
// 구현 그대로 표시 (알기 어려움)
// → PartialPick<BlogComment, 'title'> & OmitBlogComment, 'title'>

// Resolve 헬퍼 적용 후 표시 (알기 쉬움)
// → { title?: string | undefined; commentId: number; content: string; }

Resolve 헬퍼:

type Resolve<T> = T extends Function ? T : { [K in keyof T]: T[K] };

type PartiallyPartial<T, K extends keyof T> =
  Resolve<Partial<Pick<T, K>> & Omit<T, K>>;
  • 구현 세부 사항을 숨기고 실제 프로퍼티를 평탄화하여 표시
  • 다른 이름으로도 불림: Simplify, NOP, NOOP, Merge
  • 클래스에는 사용 금지: Resolve<Date> → Date의 모든 메서드가 인라인되어 매우 지저분해짐

특수 케이스 처리로 표시 개선:

type PartiallyPartial<T extends object, K extends keyof T> =
  [K] extends [never] // K가 never일 때 특수 처리 (분배 방지)
    ? T
    : T extends unknown // 분배 보존을 위한 추가 조건
      ? Resolve<Partial<Pick<T, K>> & Omit<T, K>>
      : never;

type FullComment = PartiallyPartial<BlogComment, never>;
// → BlogComment (PartialPickBlogComment, never & OmitBlogComment, never 대신)

핵심 요약:

  • 같은 타입도 표시 방식이 다를 수 있으며, 명확한 표시가 DX에 중요하다
  • 라이브러리 저자라면 Resolve 헬퍼로 타입 표시를 제어하라
  • 중요한 엣지 케이스의 타입 표시를 개선하는 특수 처리를 고려하라
  • 타입 표시 회귀를 방지하기 위해 표시 방식도 테스트하라

Item 57: 꼬리 재귀 제네릭 타입을 선호하라

문제: TypeScript는 재귀 타입 별칭의 인스턴스화 횟수를 제한하므로 깊은 재귀는 오버플로우를 일으킨다.

// 꼬리 재귀가 아닌 버전 (약 50자 이상에서 오버플로우)
type GetChars<S extends string> =
  S extends `${infer FirstChar}${infer RestOfString}`
    ? FirstChar | GetChars<RestOfString>
    : never;

type Long = GetChars<'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWX'>;
// Error: Type instantiation is excessively deep and possibly infinite.

꼬리 재귀 버전 (누산기 사용):

// 누산기(Acc)로 꼬리 재귀 구현
type GetChars<S extends string, Acc = never> =
  S extends `${infer First}${infer Rest}`
    ? GetChars<Rest, Acc | First>  // 재귀 호출이 꼬리 위치에 있음
    : Acc;

// 이제 훨씬 긴 문자열도 처리 가능

ToSnake 실제 예시:

// 꼬리 재귀가 아닌 버전 (긴 키에서 오버플로우)
type ToSnake<T extends string> =
  T extends `${infer First}${infer Rest}`
    ? First extends Uppercase<First>
      ? `_${Lowercase<First>}${ToSnake<Rest>}`
      : `${First}${ToSnake<Rest>}`
    : T;

// 꼬리 재귀 버전 (누산기 Acc 사용)
type ToSnake<T extends string, Acc extends string = ''> =
  string extends T ? string :
  T extends `${infer First}${infer Rest}`
    ? ToSnake<Rest, First extends Uppercase<First>
        ? `${Acc}_${Lowercase<First>}`
        : `${Acc}${First}`>
    : Acc;

type Long = ToSnake<'reallyDescriptiveNamePropThatsALittleTooLoquacious'>;
// → 'really_descriptive_name_prop_thats_a_little_too_loquacious' (성공!)

핵심 요약:

  • 재귀 제네릭 타입은 꼬리 재귀로 만들어라. 더 효율적이고 깊이 제한이 더 크다
  • 누산기를 추가해 꼬리 재귀로 리팩터링하라

Item 58: 복잡한 타입의 대안으로 코드 생성(Codegen)을 고려하라

Turing Tar-pit 경고: TypeScript 타입 시스템은 Turing Complete하지만, 모든 것이 가능하다고 해서 쉽거나 현명한 것은 아니다.

타입 레벨 SQL 파싱의 한계:

// 이론적으로는 가능하지만...
async function getBooks(db: Database) {
  const result = await db.query(`
    SELECT title, author, year, publisher FROM books
  `);
  return result.rows; // 타입을 정확히 파악하기 매우 복잡
}

코드 생성 도구 (PgTyped) 접근 방식:

# 설정 파일 작성 후 실행
npx pgtyped -c pgtyped.config.json
// 생성된 타입 파일
export interface selectLatestParams { publisher: string; }
export interface selectLatestResult { author: string; year: number; }

// 사용
const result = await selectLatestBookByAuthor.run({ publisher }, db);
// result: selectLatestResult[] (정확한 타입)

타입 레벨 프로그래밍 vs 코드 생성 비교:

기준 타입 레벨 프로그래밍 코드 생성
복잡도 매우 높음 보통
유지보수성 어려움 용이
동적 쿼리 지원 제한적 도구에 따라 다름
런타임 오버헤드 없음 없음
디버깅 어려움 용이

핵심 요약:

  • TypeScript 타입 시스템으로 가능하다고 해서 항상 좋은 선택은 아니다
  • 복잡한 타입 레벨 프로그래밍이 필요하다면 코드 생성 도구를 먼저 고려하라
  • PgTyped와 같은 도구는 런타임 타입 정보를 활용해 훨씬 쉽게 정확한 타입을 제공한다

Chapter 7: TypeScript Recipes

실제 TypeScript 코드를 작성할 때 마주치는 일반적인 문제들에 대한 실용적인 해법을 제공하는 챕터다.

Item 59: never 타입으로 완전성 검사(Exhaustiveness Check)를 수행하라

문제: 새로운 케이스를 추가했을 때 처리가 누락되는 “누락의 오류(errors of omission)“를 타입 시스템이 잡아주지 못할 수 있다.

never를 이용한 완전성 검사:

type Coord = [x: number, y: number];
interface Box { type: 'box'; topLeft: Coord; size: Coord; }
interface Circle { type: 'circle'; center: Coord; radius: number; }
interface Line { type: 'line'; start: Coord; end: Coord; }
type Shape = Box | Circle | Line;

function assertUnreachable(value: never): never {
  throw new Error(`Missed a case! ${value}`);
}

function drawShape(shape: Shape, context: CanvasRenderingContext2D) {
  switch (shape.type) {
    case 'box': context.rect(...shape.topLeft, ...shape.size); break;
    case 'circle': context.arc(...shape.center, shape.radius, 0, 2 * Math.PI); break;
    // case 'line' 누락 시:
    default: assertUnreachable(shape); // Error: Line은 never에 할당 불가
  }
}

반환값이 있는 함수에서의 완전성 검사:

function getArea(shape: Shape): number {
  // 반환 타입 annotation이 있으면 누락된 케이스를 바로 잡아냄
  switch (shape.type) {
    case 'box': const [width, height] = shape.size; return width * height;
    case 'circle': return Math.PI * shape.radius ** 2;
    case 'line': return 0;
    default: return assertUnreachable(shape); // 보험용 유지
  }
}

다른 완전성 검사 패턴:

// never에 직접 할당
default:
  const exhaustiveCheck: never = shape; // Error if shape is not never
  throw new Error(`Missed a case: ${exhaustiveCheck}`);

// satisfies 연산자 사용
default:
  shape satisfies never; // Error if shape is not never
  throw new Error(`Missed a case: ${shape}`);

두 타입의 조합(cross-product) 완전성 검사:

type Play = 'rock' | 'paper' | 'scissors';

function shoot(a: Play, b: Play) {
  const pair = `${a},${b}` as `${Play},${Play}`;
  switch (pair) {
    case 'rock,rock': case 'paper,paper': case 'scissors,scissors':
      console.log('draw'); break;
    case 'rock,scissors': case 'paper,rock': case 'scissors,paper':
      console.log('A wins'); break;
    case 'rock,paper': case 'paper,scissors': case 'scissors,rock':
      console.log('B wins'); break;
    default: assertUnreachable(pair); // 누락 시 에러
  }
}

핵심 요약:

  • never 타입에 할당하는 방식으로 모든 케이스가 처리되었는지 검사하라
  • 반환 타입 annotation을 추가하면 누락된 케이스를 더 일찍 잡을 수 있다
  • assertUnreachable은 나중에 케이스가 추가될 때를 위해 유지하라 (런타임 보호도 제공)

Item 60: 객체를 순회하는 방법을 알아라

문제: 타입이 명확한 객체를 for...in으로 순회할 때 TypeScript가 에러를 내는 이유

const obj = { one: 'uno', two: 'dos', three: 'tres' };
for (const k in obj) {
  const v = obj[k]; // Error: obj has no index signature
  // k의 타입이 string (one | two | three가 아님)
}

k의 타입이 string인가: 구조적 타이핑에 의해 obj에 선언되지 않은 추가 프로퍼티가 있을 수 있다. TypeScript는 안전하게 string으로 추론한다.

interface ABC { a: string; b: string; c: number; }
function foo(abc: ABC) {
  for (const k in abc) { /* k: string */ }
}
const x = { a: 'a', b: 'b', c: 2, d: new Date() };
foo(x); // 완전히 유효한 호출 → k가 'd'일 수 있음

해결 방법 1: keyof typeof 단언:

const obj = { one: 'uno', two: 'dos', three: 'tres' };
for (const kStr in obj) {
  const k = kStr as keyof typeof obj; // 'one' | 'two' | 'three'
  const v = obj[k]; // OK, 타입 안전
}

주의: 함수 파라미터로 받은 객체에 이 단언을 사용하면 d 같은 추가 프로퍼티의 값 타입이 틀릴 수 있음

해결 방법 2: Object.entries 사용:

for (const [k, v] of Object.entries(obj)) {
  // k: string, v: string (가장 안전한 방법)
  console.log(k, v);
}

핵심 요약:

  • for...in에서 k의 타입이 string인 것은 구조적 타이핑 때문으로 TypeScript가 옳다
  • 직접 소유한 고정 구조 객체에는 keyof typeof 단언이 안전하게 사용 가능하다
  • 함수 파라미터로 받은 객체는 추가 프로퍼티 가능성이 있어 단언에 주의가 필요하다

Item 61: Record 타입으로 값을 동기화 상태로 유지하라

“fail open vs fail closed” 딜레마: 새 프로퍼티 추가 시 처리를 누락하면?

  • fail open (보수적): 변경 시 항상 리렌더링 → 정확하지만 과도할 수 있음
  • fail closed (최적화): 명시적 목록만 리렌더링 → 효율적이지만 누락 위험

Record 타입으로 강제 동기화 (TypeScript가 준 세 번째 선택: “fail”):

interface ScatterProps {
  xs: number[];
  ys: number[];
  xRange: [number, number];
  yRange: [number, number];
  color: string;
  onClick?: (x: number, y: number, index: number) => void;
}

// ScatterProps의 모든 키를 boolean으로 매핑 (누락 불가)
const REQUIRES_UPDATE: Record<keyof ScatterProps, boolean> = {
  xs: true,
  ys: true,
  xRange: true,
  yRange: true,
  color: true,
  onClick: false,
};
// ScatterProps에 onDoubleClick 추가 시:
// Error: Property 'onDoubleClick' is missing in type...
function shouldUpdate(oldProps: ScatterProps, newProps: ScatterProps): boolean {
  for (const kStr in oldProps) {
    const k = kStr as keyof ScatterProps;
    if (oldProps[k] !== newProps[k] && REQUIRES_UPDATE[k]) {
      return true;
    }
  }
  return false;
}

핵심 요약:

  • fail open / fail closed 딜레마를 인식하라
  • Record 타입으로 관련 값과 타입을 동기화된 상태로 유지하라
  • 인터페이스에 새 프로퍼티를 추가할 때 선택을 강제하는 데 Record 타입을 활용하라

Item 62: 가변 인자 함수를 모델링하는 데 Rest 파라미터와 튜플 타입을 사용하라

문제: 파라미터 수/타입이 다른 타입에 의존하는 함수를 어떻게 모델링할까?

interface RouteQueryParams {
  '/': {};
  '/search': { query: string; language?: string };
  '/settings': {};
}

// 타입에 따라 두 번째 파라미터가 필요하거나 필요 없는 함수
function getURL(route: '/'): string;
function getURL(route: '/search', params: { query: string; language?: string }): string;

조건부 타입 + 튜플 타입으로 일반화:

type RouteParams<Route extends keyof RouteQueryParams> =
  RouteQueryParams[Route] extends Record<string, never>
    ? [route: Route]
    : [route: Route, params: RouteQueryParams[Route]];

function getURL<Route extends keyof RouteQueryParams>(
  ...args: RouteParams<Route>
): string {
  // ...
}

getURL('/');                                      // OK (파라미터 불필요)
getURL('/search', { query: 'typescript' });       // OK
getURL('/search');                                // Error: params 필요
getURL('/', { query: 'typescript' });             // Error: / 는 파라미터 불필요

중요: 튜플 타입 요소에 레이블(이름)을 붙이지 않으면 args_0 같은 의미 없는 파라미터 이름이 표시됨

핵심 요약:

  • Rest 파라미터와 튜플 타입으로 시그니처가 타입에 의존하는 함수를 모델링하라
  • 파라미터 수/타입 간의 관계를 조건부 타입으로 모델링하라
  • 튜플 타입 요소에 레이블을 붙여 의미 있는 파라미터 이름을 제공하라

Item 63: 배타적 OR을 모델링하는 데 Optional never 프로퍼티를 사용하라

TypeScript의 |는 포함적 OR(inclusive or):

interface ThingOne { shirtColor: string; }
interface ThingTwo { hairColor: string; }
type Thing = ThingOne | ThingTwo;

// 두 인터페이스를 동시에 만족하는 값도 Thing에 할당 가능
const bothThings: Thing = { shirtColor: 'red', hairColor: 'blue' }; // OK!

방법 1: 태그드 유니온 (가장 권장):

interface ThingOneTag { type: 'one'; shirtColor: string; }
interface ThingTwoTag { type: 'two'; hairColor: string; }
type Thing = ThingOneTag | ThingTwoTag;
// 'one'과 'two'는 동시에 가질 수 없으므로 배타적 OR

방법 2: Optional never 프로퍼티:

interface OnlyThingOne { shirtColor: string; hairColor?: never; }
interface OnlyThingTwo { hairColor: string; shirtColor?: never; }
type ExclusiveThing = OnlyThingOne | OnlyThingTwo;

const allThings: ExclusiveThing = { shirtColor: 'red', hairColor: 'blue' }; // Error!

2D 벡터에 z 프로퍼티 금지:

interface Vector2D {
  x: number;
  y: number;
  z?: never; // 3D 벡터가 잘못 전달되는 것을 방지
}

일반화된 XOR 헬퍼 타입:

type XOR<T1, T2> =
  | (T1 & { [k in Exclude<keyof T2, keyof T1>]?: never })
  | (T2 & { [k in Exclude<keyof T1, keyof T2>]?: never });

type ExclusiveThing = XOR<ThingOne, ThingTwo>;

핵심 요약:

  • TypeScript에서 |는 포함적 OR이다. “둘 다” 케이스를 처리하거나 금지하라
  • 태그드 유니온으로 배타적 OR을 모델링하는 것이 가장 편리하다
  • 태그를 추가하기 어려운 상황에서는 optional never 프로퍼티를 사용하라

Item 64: 명목적 타이핑을 위해 브랜드(Brand)를 고려하라

문제: TypeScript의 구조적 타이핑은 의미상 다른 타입이 혼용되는 것을 막지 못한다.

interface Vector2D { x: number; y: number; }
function calculateNorm(p: Vector2D): number {
  return Math.sqrt(p.x ** 2 + p.y ** 2);
}
const vec3D = { x: 3, y: 4, z: 1 };
calculateNorm(vec3D); // OK! (구조적으로 호환되므로)

방법 1: 런타임 태그 (tagged union):

interface Vector2D { type: '2d'; x: number; y: number; }
// 런타임 오버헤드 발생, 객체 타입에만 적용 가능

방법 2: 브랜드(Brand) - 타입 레벨 전용:

type AbsolutePath = string & { _brand: 'AbsolutePath' };

function isAbsolutePath(path: string): path is AbsolutePath {
  return path.startsWith('/');
}

function listAbsoluteDir(path: AbsolutePath): string[] { /* ... */ }

const userInput = '/tmp/test';
if (isAbsolutePath(userInput)) {
  listAbsoluteDir(userInput); // OK
}

listAbsoluteDir('/tmp/test'); // Error: string은 AbsolutePath가 아님
listAbsoluteDir(userInput as AbsolutePath); // 명시적 단언 필요

브랜딩의 응용 예시:

// 정렬된 배열 보장
type SortedList<T> = T[] & { _brand: 'sorted' };

function isSorted<T>(xs: T[]): xs is SortedList<T> {
  for (let i = 1; i < xs.length; i++) {
    if (xs[i] < xs[i - 1]) return false;
  }
  return true;
}

function binarySearch<T>(xs: SortedList<T>, x: T): boolean { /* ... */ }

// 정렬 여부를 확인해야만 binarySearch 사용 가능
const nums = [1, 3, 2, 5];
if (isSorted(nums)) {
  binarySearch(nums, 3); // OK
}
binarySearch(nums, 3); // Error: number[] is not SortedList<number>

브랜드 타입 비교:

방법 런타임 오버헤드 사용 가능한 타입 안전성
런타임 태그 있음 객체 타입 높음
브랜드(타입 레벨) 없음 모든 타입 중간

핵심 요약:

  • 구조적 타이핑이 맞지 않는 경우 브랜드로 명목적 타이핑을 시뮬레이션할 수 있다
  • 브랜드는 타입 레벨에만 존재하므로 런타임 오버헤드가 없다
  • 브랜드는 코드베이스 전체에 안전성 제약을 강제하는 타입 가드와 함께 사용한다

Chapter 8: 타입 선언과 타입 의존성 관리

아이템 65: TypeScript와 @types는 devDependencies에 추가하기

  • npm 의존성의 세 가지 종류를 구분해야 한다.
종류 설명 예시
dependencies 런타임에 필요한 패키지 react
devDependencies 개발/테스트 시에만 필요한 패키지 typescript, @types/*
peerDependencies 런타임에 필요하지만 사용자가 직접 선택할 패키지 React 컴포넌트 라이브러리의 react
  • TypeScript를 전역 설치하는 것은 나쁜 습관이다. 팀원마다 다른 버전을 사용할 수 있고, 프로젝트 세팅 단계가 추가된다.
  • TypeScript는 devDependencies에 두고, npx tsc로 실행하면 npm이 설치한 버전을 사용한다.
  • @types/react처럼 타입 선언 패키지도 런타임에는 필요 없으므로 devDependencies에 두어야 한다.
// package.json
{
  "devDependencies": {
    "@types/react": "^18.2.23",
    "typescript": "^5.2.2"
  },
  "dependencies": {
    "react": "^18.2.0"
  }
}
  • 웹 앱이라도 devDependencies를 분리하면 npm install --production으로 프로덕션 이미지를 슬림하게 유지할 수 있고, Renovate/Dependabot 같은 자동 업데이트 도구가 프로덕션 의존성을 우선 처리하도록 설정할 수 있다.

핵심 원칙: TypeScript와 @types/* 패키지는 항상 devDependencies에 넣어라.

아이템 66: 타입 선언에 관련된 세 가지 버전 이해하기

타입 선언(@types)을 사용하면 고려해야 할 버전이 세 가지가 된다.

  1. 라이브러리 버전 (예: react@18.2.0)
  2. 타입 선언 버전 (예: @types/react@18.2.23)
  3. TypeScript 버전 (예: typescript@5.2.2)
  • 라이브러리와 @types의 메이저/마이너 버전은 일치해야 하고, 패치 버전은 달라도 된다. (타입 선언에 더 많은 수정이 생기는 것이 정상)
  • 버전 불일치로 발생하는 문제 유형 4가지:
    • 라이브러리를 업데이트했지만 타입 선언을 업데이트하지 않은 경우 → 새 기능 사용 시 타입 에러 발생
    • 타입 선언이 라이브러리보다 앞서간 경우 → 런타임 코드와 타입 불일치
    • 타입 선언이 현재 TypeScript보다 최신 버전을 요구하는 경우 → 타입 선언 파일 자체에서 에러 발생
    • @types/foo를 두 곳에서 다른 버전으로 의존하는 경우 → 중복 타입 선언 에러 발생
# 특정 TypeScript 버전용 타입 설치
npm install --save-dev @types/react-ts4.9
# 중복 타입 의존성 추적
npm ls @types/foo

타입 선언을 번들링할 것인가, DefinitelyTyped에 게시할 것인가?

방식 장점 단점
번들링 (내장) 버전 불일치 없음, tsc --declaration으로 자동 생성 가능 타입 에러 수정 불가, TS 버전 업에 취약
DefinitelyTyped 커뮤니티 유지, MS가 TS 업데이트 시 호환성 확인, 구버전 유지 가능 별도 패키지 관리 필요

권장: 라이브러리가 TypeScript로 작성된 경우 번들링, JavaScript 라이브러리는 DefinitelyTyped를 사용하라.

아이템 67: 공개 API에 등장하는 모든 타입 export하기

  • 내보내지 않은 타입이라도 공개 함수 시그니처에 쓰이면, 사용자가 ReturnType이나 Parameters로 추출할 수 있다.
interface SecretName { first: string; last: string; }
interface SecretSanta { name: SecretName; gift: string; }

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

// 사용자가 타입을 추출하는 방법
type MySanta = ReturnType<typeof getGift>;   // SecretSanta
type MyName = Parameters<typeof getGift>; // SecretName
  • export하지 않아도 이미 공개 API의 일부가 되어 있으므로, 처음부터 export해서 사용자에게 편의를 제공하라.

아이템 68: API 주석에 TSDoc 사용하기

  • 일반 인라인 주석은 에디터 툴팁에 표시되지 않지만, /** ... */ 형식의 JSDoc/TSDoc 주석은 에디터 툴팁으로 노출된다.
/**
 * 인사말을 생성한다.
 * @param name 인사할 사람의 이름
 * @param title 그 사람의 직함
 * @returns 사람이 읽기 좋은 형태의 인사말
 */
function greetFullTSDoc(name: string, title: string): string {
  return `Hello ${title} ${name}`;
}

/** 시간과 장소에서 수행된 측정값 */
interface Measurement {
  /** 측정이 이루어진 위치 */
  position: Vector3D;
  /** 측정 시각. epoch 이후 초 단위. */
  time: number;
  /** 관측된 운동량 */
  momentum: Vector3D;
}
  • TSDoc은 Markdown 형식을 지원한다.
  • @param, @returns 태그를 활용하면 파라미터별로 문서가 표시된다.
  • @deprecated 태그를 사용하면 에디터에서 취소선으로 표시되어 사용자에게 즉시 알린다.
  • JSDoc에서 타입 정보(@param {string})는 쓰지 말 것. TypeScript 타입이 있으면 중복이다.
  • Partial, Pick 같은 동형(homomorphic) 매핑 타입을 거쳐도 TSDoc이 유지된다.

아이템 69: 콜백에서 this가 API의 일부라면 타입 선언에 포함하기

  • JavaScript에서 this는 동적으로 바인딩된다. 변수로 추출하면 thisundefined가 되어 런타임 오류가 발생할 수 있다.
// this 파라미터를 명시한 콜백 타입 선언
function addKeyListener(
  el: HTMLElement,
  listener: (this: HTMLElement, e: KeyboardEvent) => void
): void {
  el.addEventListener('keydown', e => listener.call(el, e));
}

// 사용 시 this가 HTMLElement로 타입 안전하게 추론됨
declare let el: HTMLElement;
addKeyListener(el, function(e) {
  console.log(this.innerHTML); // this: HTMLElement
});
  • this 파라미터는 특수 파라미터로, 실제 위치 인수가 아니다.
  • this를 설정하는 콜백을 강제하면 화살표 함수 사용이 어려워지므로, 새로운 API에서는 동적 this 바인딩을 피하는 것이 좋다.

아이템 70: 의존성을 끊기 위한 미러 타입 사용하기

  • Node.js용 CSV 파서를 만들어 Buffer를 지원할 때, @types/node에 의존하면 웹 개발자들이 불필요하게 Node.js 타입을 설치해야 한다.
  • 구조적 타이핑을 활용해 필요한 메서드만 포함하는 미러 인터페이스를 직접 정의하면 의존성을 끊을 수 있다.
// @types/node에 의존하는 대신, 필요한 부분만 직접 정의
export interface CsvBuffer {
  toString(encoding?: string): string;
}

export function parseCSV(
  contents: string | CsvBuffer
): { [column: string]: string }[] { /* ... */ }
  • 실제 BufferCsvBuffer에 구조적으로 호환되는지는 단위 테스트로 검증한다.
import { Buffer } from 'node:buffer';
import { parseCSV } from './parse-csv';

test('parse CSV in a buffer', () => {
  expect(parseCSV(new Buffer('col1,col2\n1,val2', 'utf-8')))
    .toEqual([{ col1: '1', col2: 'val2' }]);
});
  • 이 기법은 유닛 테스트와 프로덕션 시스템 사이의 의존성을 끊을 때도 유용하다.
  • 의존성 트리 전체를 제거할 수 있어 컴파일러 성능에도 큰 긍정적 효과가 있다.

아이템 71: 모듈 보강으로 타입 개선하기

  • TypeScript 타입에 역사적으로 부정확한 부분이 있을 때, **선언 병합(declaration merging)**으로 기존 타입을 덮어쓸 수 있다.
  • 대표적인 예: JSON.parse는 역사적으로 any를 반환하지만, unknown이 더 안전하다.
// declarations/safe-json.d.ts
interface JSON {
  parse(
    text: string,
    reviver?: (this: any, key: string, value: any) => any
  ): unknown; // any -> unknown으로 변경
}

// 이제 JSON.parse의 결과를 사용하려면 타입 단언 필요
interface ApiResponse { lastModified: number; }
const response = JSON.parse(apiResponse) as ApiResponse;
const cacheExpirationTime = response.lastModified * 3600; // OK
  • fetch API의 Response.prototype.json도 같은 방식으로 개선 가능:
// declarations/safe-response.d.ts
interface Body {
  json(): Promise<unknown>;
}
  • 문제가 있는 생성자/메서드를 void 또는 에러 문자열 리터럴을 반환하도록 만들어 사실상 금지할 수 있다.
// Set 생성자에 string을 넘기는 것을 경고
interface SetConstructor {
  /** @deprecated */
  new(str: string): 'Error! new Set(string) is banned.';
}

const s = new Set('abc'); // const s: 'Error! new Set(string) is banned.'
  • 주의사항: 이는 타입 레벨에서만 작동한다. 런타임 동작은 변경되지 않는다. 타입이 현실과 달라지면 혼란을 일으킬 수 있으므로 신중하게 사용해야 한다.
  • ts-reset npm 패키지에서 이런 내장 타입 개선의 모음을 찾아볼 수 있다.

Chapter 9: 코드 작성과 실행

아이템 72: ECMAScript 기능을 TypeScript 기능보다 선호하기

TypeScript는 초창기에 JavaScript에 없던 기능을 독자적으로 추가했다. TC39가 표준으로 많은 기능을 수용한 이후 TypeScript의 원칙이 “TC39가 런타임을 정의하고, TypeScript는 타입 공간에서만 혁신한다” 로 바뀌었다. 그러나 그 이전에 추가된 몇 가지 기능은 이 원칙에 맞지 않으므로 피해야 한다.

Enum

  • 숫자 열거형: number 타입이 할당 가능해서 타입 안전하지 않다.
  • 문자열 열거형: 타입 안전하지만 구조적 타이핑이 아닌 명목적 타이핑을 사용해, JavaScript 사용자와 TypeScript 사용자 경험이 달라진다.
  • const enum: 컴파일 시 완전히 사라지지만, preserveConstEnums 플래그와 함께 쓰면 런타임 코드가 생긴다.
  • 권장 대안: 리터럴 타입의 유니온
// enum 대신 리터럴 유니온을 사용하라
type Flavor = 'vanilla' | 'chocolate' | 'strawberry';

let favoriteFlavor: Flavor = 'chocolate'; // OK
favoriteFlavor = 'americone dream'; // 오류: Flavor에 없는 값

파라미터 프로퍼티

// 파라미터 프로퍼티 (편리하지만 비표준적)
class Person {
  constructor(public name: string) {}
}

// 명시적 방식 (더 명확)
class Person {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}
  • 파라미터 프로퍼티는 컴파일 시 코드를 생성하는 몇 안 되는 TypeScript 구문 중 하나다.
  • 파라미터 프로퍼티와 일반 프로퍼티가 혼재하면 클래스 설계를 파악하기 어려워진다.

네임스페이스와 트리플 슬래시 임포트

  • ES2015 이전 TypeScript만의 모듈 시스템이었다. 지금은 ECMAScript 모듈(import/export)을 사용해야 한다.
// 과거 방식 (피해야 함)
/// <reference path="other.ts" />
namespace foo { export function bar() {} }

// 현재 방식
import { bar } from './other';

experimentalDecorators

  • 2023년 표준 데코레이터(Stage 3)가 도입되었으므로, experimentalDecorators 플래그 없이 표준 데코레이터를 사용해야 한다.
  • tsconfig.jsonexperimentalDecorators: true가 있으면 비표준 데코레이터를 사용 중인 것이다. 가능하다면 제거하라.
// 표준 ECMAScript 데코레이터 예시
class Greeter {
  greeting: string;
  constructor(message: string) { this.greeting = message; }

  @logged  // 데코레이터
  greet() { return `Hello, ${this.greeting}`; }
}

function logged(originalFn: any, context: ClassMethodDecoratorContext) {
  return function(this: any, ...args: any[]) {
    console.log(`Calling ${String(context.name)}`);
    return originalFn.call(this, ...args);
  };
}

멤버 가시성 수정자 (private, protected, public)

  • TypeScript의 private타입 레벨에서만 동작하며, 런타임에는 아무런 보호를 제공하지 않는다.
// TypeScript private: 런타임에 노출됨
class Diary { private secret = 'cheated on my English test'; }
const diary = new Diary();
(diary as any).secret; // 런타임에 접근 가능!
  • ECMAScript 표준 private 필드(#)를 사용하라. 런타임에서도 진짜로 비공개다.
class PasswordChecker {
  #passwordHash: number;
  constructor(passwordHash: number) { this.#passwordHash = passwordHash; }
  checkPassword(password: string): boolean {
    return hash(password) === this.#passwordHash;
  }
}

const checker = new PasswordChecker(hash('s3cret'));
checker.#passwordHash; // 오류: 클래스 외부에서 접근 불가
  • public은 기본값이므로 명시할 필요 없다.
  • protected는 상속을 암시하는데, 일반적으로 컴포지션이 상속보다 선호되므로 실제로 쓸 일이 드물다.
  • readonly는 타입 레벨 구문이므로 사용해도 무방하다.

요약: TypeScript를 JavaScript로 변환하는 것은 타입만 제거하면 된다. Enum, 파라미터 프로퍼티, 트리플 슬래시 임포트, experimentalDecorators, 멤버 가시성 수정자는 이 규칙의 역사적 예외다. 이러한 비표준 기능은 피하라.

아이템 73: 소스맵으로 TypeScript 디버깅하기

  • TypeScript를 실행할 때는 실제로 컴파일된 JavaScript가 실행된다. async/await를 ES5로 컴파일하면 상태 머신으로 바뀌어 원본 코드와 전혀 달라진다.
  • 소스맵(source map)은 생성된 파일의 위치와 심볼을 원본 소스의 위치와 심볼로 매핑한다.
// tsconfig.json
{
  "compilerOptions": {
    "sourceMap": true
  }
}
  • 활성화하면 각 .ts 파일에 대해 .js.js.map 파일이 생성되고, 브라우저 디버거에서 원본 TypeScript 소스로 디버깅할 수 있다.
# Node.js 디버깅
tsc bedtime.ts
node --inspect-brk bedtime.js
# 이후 chrome://inspect 에서 원본 TS 소스로 디버깅 가능
  • 번들러/minifier를 사용 중이라면 소스맵이 최종 JS에서 원본 TS까지 이어지는지 확인해야 한다.
  • 주의: 소스맵에 원본 소스 코드가 인라인 복사될 수 있다. 내부 주석이나 버그 트래커 URL 등 공개하고 싶지 않은 내용이 있을 수 있으므로, 프로덕션에 소스맵을 서빙할 때 주의해야 한다.
  • .d.ts 파일도 declarationMap: true로 소스맵을 생성할 수 있고, 에디터에서 “Go to Definition” 기능의 정확도가 높아진다.

아이템 74: 런타임에 타입 재구성하는 방법 알기

  • TypeScript 타입은 런타임에 지워진다. 입력 유효성 검사 같은 경우에는 런타임 타입 정보가 필요하다.

문제 상황

interface CreateComment { postId: string; title: string; body: string; }

// 타입과 유효성 검사 로직이 중복되어 있어, 하나를 변경하면 다른 하나도 수동으로 맞춰야 함
app.post('/comment', (request, response) => {
  const body = request.body;
  if (
    !body || typeof body !== 'object' ||
    Object.keys(body).length !== 3 ||
    !('postId' in body) || typeof body.postId !== 'string'
    // ...
  ) { return response.status(400).send('Invalid request'); }
  const comment = body as CreateComment;
});

해결책 1: 다른 소스에서 타입 생성

  • OpenAPI/GraphQL 스키마가 있다면 그것을 단일 진실 공급원(source of truth) 으로 삼고, json-schema-to-typescript 같은 도구로 TypeScript 타입을 자동 생성한다.

해결책 2: 런타임 라이브러리로 타입 정의 (Zod 등)

import { z } from 'zod';

// 런타임 값이 곧 타입의 소스
const createCommentSchema = z.object({
  postId: z.string(),
  title: z.string(),
  body: z.string(),
});

// 정적 타입도 이로부터 파생
type CreateComment = z.infer<typeof createCommentSchema>;

app.post('/comment', (request, response) => {
  const body = request.body;
  try {
    const comment = createCommentSchema.parse(body); // 타입 검증 + 타입 추론 동시에
    return response.status(200).send('ok');
  } catch (e) {
    return response.status(400).send('Invalid request');
  }
});
  • Zod 장점: 빌드 단계 불필요, 이메일/정수 같은 세밀한 제약 표현 가능
  • Zod 단점: 타입 정의 방법이 두 가지(interface와 z.object)가 됨. 런타임 타입 시스템이 전파되어 모든 관련 타입을 Zod로 다시 정의해야 할 수 있음

해결책 3: TypeScript 타입에서 런타임 값 생성

# TypeScript 타입 -> JSON Schema로 변환
npx typescript-json-schema api.ts > api.schema.json
import Ajv from 'ajv';
import apiSchema from './api.schema.json';
import type { CreateComment } from './api';

const ajv = new Ajv();

app.post('/comment', (request, response) => {
  const body = request.body;
  if (!ajv.validate(apiSchema.definitions.CreateComment, body)) {
    return response.status(400).send('Invalid request');
  }
  const comment = body as CreateComment;
  // ...
});
  • 장점: 기존 TypeScript 타입 시스템을 그대로 활용, 외부 라이브러리 타입 참조 가능
  • 단점: 새 빌드 단계 필요, api.ts 변경 시 api.schema.json 재생성 필요

아이템 75: DOM 계층 구조 이해하기

DOM 요소 타입에는 계층 구조가 있으며, TypeScript에서 이를 이해해야 에러를 올바르게 해결할 수 있다.

EventTarget
  └── Node
        ├── Text
        ├── Comment
        └── Element
              └── HTMLElement
                    ├── HTMLButtonElement
                    ├── HTMLInputElement
                    └── HTMLParagraphElement
타입 예시
EventTarget window, XMLHttpRequest
Node document, Text, Comment
Element HTMLElement들, SVGElement
HTMLElement <i>, <b>
HTMLButtonElement <button>
  • EventTarget에는 classList 같은 HTML 속성이 없다. currentTarget의 타입이 EventTarget이어서 오류가 발생한다.
  • document.getElementsByTagName('p')HTMLParagraphElement (구체적 타입 추론)
  • document.getElementById('my-div')HTMLElement | null (구체적 타입 추론 불가)
// 타입 단언이 적절한 경우
const div = document.getElementById('my-div') as HTMLDivElement;

// 런타임 타입 검사 (더 안전)
const div = document.getElementById('my-div');
if (div instanceof HTMLDivElement) {
  console.log(div); // const div: HTMLDivElement
}
  • Event에도 계층 구조가 있다: EventUIEventMouseEvent (clientX, clientY 포함)
  • 핵심 해결책: 이벤트 핸들러를 인라인으로 작성하거나, 파라미터 타입을 MouseEvent처럼 구체적으로 명시하면 TypeScript가 더 정확한 타입을 추론한다.
function addDragHandler(el: HTMLElement) {
  el.addEventListener('mousedown', eDown => {
    // eDown: MouseEvent (인라인이므로 TypeScript가 컨텍스트 활용)
    const dragStart = [eDown.clientX, eDown.clientY];
    const handleUp = (eUp: MouseEvent) => {
      el.classList.remove('dragging');
      el.removeEventListener('mouseup', handleUp);
    };
    el.addEventListener('mouseup', handleUp);
  });
}

아이템 76: 환경을 정확하게 모델링하기

  • TypeScript 코드는 특정 런타임(V8, SpiderMonkey 등)과 환경(브라우저, Node.js, Deno 등)에서 실행된다. TypeScript가 이 환경을 정확히 모델링할수록 에러를 더 잘 잡는다.
// tsconfig.json
{
  "compilerOptions": {
    "lib": ["dom", "es2021"]
  }
}
  • dom을 포함하면 브라우저 환경을 모델링한다.
  • es2021은 해당 연도 JS 표준 기능을 지원한다고 선언한다. 더 최신 기능(예: array.toSorted)을 사용하면 타입 에러가 발생한다.
// 전역 변수 모델링: user-info-global.d.ts
interface UserInfo { name: string; accountId: string; }
declare global {
  interface Window {
    userInfo: UserInfo;
  }
}
// webpack CSS/이미지 모듈 모델링: webpack-imports.d.ts
declare module '*.jpg' {
  const src: string;
  export default src;
}
  • 라이브러리 타입 패키지의 버전이 실제로 로드되는 라이브러리 버전과 일치해야 한다. 불일치 시 거짓 에러가 발생하거나 실제 에러를 놓친다.
  • 클라이언트/서버처럼 환경이 다른 코드는 별도의 tsconfig.json + 프로젝트 참조로 분리해야 한다.

아이템 77: 타입 체크와 단위 테스트의 관계 이해하기

타입 체크와 단위 테스트는 프로그램 검증의 상호 보완적인 방법이다.

단위 테스트 타입 체크
역할 특정 입력에서의 올바른 동작 증명 (하한선) 특정 클래스의 오류 불가능성 증명 (상한선)
한계 모든 입력 조합을 테스트 불가 동작 버그(예: a - b vs a + b)는 잡지 못함
// 타입 체크가 잡는 버그
function add(a: number, b: number): number {
  if (isNaN(a) || isNaN(b)) return 'Not a number!'; // 오류: string은 number 아님
  return (a | 0) + (b | 0);
}

// 단위 테스트가 잡는 버그 (타입 체크는 통과)
function add(a: number, b: number): number {
  return a - b; // 타입은 맞지만 논리가 틀림
}
  • 타입 에러인 입력에 대한 단위 테스트는 작성하지 말 것. 타입 체커에게 맡겨라.
  • 단, 보안이나 데이터 손상과 관련된 사이드 이펙트가 있는 함수는 타입 레벨로만 강제해서는 안 된다. // @ts-expect-error와 함께 단위 테스트를 작성하라.
test('invalid update', () => {
  // @ts-expect-error - ID 업데이트는 허용되지 않음을 단위 테스트로도 검증
  expect(updateUserById('123', { id: '234' })).toReject();
});
  • 타입에 로직이 포함된 경우(Chapter 6), 반드시 타입 테스트를 작성해야 한다.
  • 타입은 리팩터링 시 에러를 잡고 언어 서비스(자동완성 등)를 활성화하는 부가 가치도 있다.

아이템 78: 컴파일러 성능에 신경 쓰기

TypeScript는 런타임 성능에 영향을 주지 않지만, 개발 툴 성능에는 영향을 준다.

도구 느려지는 경우 영향
tsc CI/빌드 시 전체 타입 체크 배포 속도 저하
tsserver 에디터 언어 서비스 에러 표시 지연, 에디터 느려짐

최적화 전략 1: 타입 체크와 빌드 분리 (tsc 성능)

# ts-node에서 타입 체크 생략 (번들 실행 속도 크게 향상)
ts-node --transpileOnly hello.ts   # 0.12초
ts-node hello.ts                   # 1.60초 (타입 체크 포함)
  • 번들러(webpack, vite)나 ts-node를 transpile-only 모드로 실행하면 반복 개발 속도가 크게 향상된다.
  • CI에서는 반드시 tsc로 전체 타입 체크를 수행해야 한다.

최적화 전략 2: 미사용 의존성과 데드 코드 제거 (빌드/에디터 모두)

# 미사용 로컬 심볼 감지
# tsconfig.json에 "noUnusedLocals": true 추가

# 미사용 export 감지
npx knip

# TypeScript가 처리하는 파일 목록 확인
tsc --listFiles

# 파일 크기 기준 트리맵 시각화
tsc --noEmit --listFiles | xargs stat -f '%z %N' | npx webtreemap-cli

최적화 전략 3: 증분 빌드와 프로젝트 참조 (tsc 성능)

// tsconfig-base.json
{
  "compilerOptions": {
    "declaration": true,
    "composite": true
  }
}

// tsconfig.json (루트)
{
  "files": [],
  "references": [{ "path": "./src" }, { "path": "./test" }]
}

// src/tsconfig.json
{
  "extends": "../tsconfig-base.json",
  "compilerOptions": { "outDir": "../dist/src", "rootDir": "." }
}

// test/tsconfig.json
{
  "extends": "../tsconfig-base.json",
  "references": [{ "path": "../src" }]
}
  • 프로젝트 참조를 설정하면, src의 구현만 변경했을 때(API 변경 없음) test 프로젝트는 재빌드하지 않아도 된다.
  • 프로젝트 참조 주의사항:
    • declaration: truenoEmit 미사용이 전제 조건이다.
    • 대규모 모노레포에 가장 유효하다(자체 코드가 서드파티보다 많을 때).
    • 프로젝트를 너무 많이 쪼개면 오히려 역효과가 난다.

최적화 전략 4: 타입 단순화 (빌드/에디터 모두)

  • 거대한 유니온 타입을 피하라. (type Year = 2000 | 2001 | ... | 2999는 1000개 원소 유니온이다.)
  • 교차 타입 대신 인터페이스 확장을 사용하라. (TypeScript가 더 효율적으로 처리한다.)
  • 함수 반환 타입을 명시적으로 표기하면 TypeScript가 추론하는 작업을 줄여준다.
  • 복잡한 재귀 타입은 특히 성능에 주의해야 한다.

Chapter 10: 현대화와 마이그레이션

아이템 79: 모던 JavaScript 작성하기

TypeScript는 ES5까지 컴파일 가능한 트랜스파일러이기도 하다. 즉 모던 JS 기능을 자유롭게 쓰고 TypeScript가 구버전으로 변환하게 하면 된다. 모던 JS 작성은 TypeScript 마이그레이션의 훌륭한 첫 단계다.

ES 모듈 사용

// CommonJS 방식 (피해야 함)
const b = require('./b');

// ES 모듈 방식 (권장)
import * as b from './b';
export const name = 'Module B';

프로토타입 대신 클래스 사용

// 구식: TypeScript가 이해하기 어려움
function Person(first, last) {
  this.first = first;
  this.last = last;
}
Person.prototype.getName = function() { return `${this.first} ${this.last}`; };

// 현대식: TypeScript가 잘 이해함
class Person {
  constructor(public first: string, public last: string) {}
  getName() { return `${this.first} ${this.last}`; }
}

기타 모던 JS 기능

  • var 대신 letconst 사용 (스코프 문제 방지)
  • C 스타일 for 루프 대신 for-of 또는 map/filter 사용
  • 콜백이나 순수 Promise 대신 async/await 사용
  • 함수 표현식 대신 화살표 함수 (간결함, this 값 유지)
  • 기본 파라미터 값 활용 (function foo(x = 123))
  • 객체 리터럴 단축 표기 ({ x }{ x: x })
  • 구조 분해 할당 (const [x, y] = pair)
  • || 대신 ?? (null 체크 명확화)
  • MapSet 활용 (일반 객체와 연관 배열 혼용 문제 방지)
  • 옵셔널 체이닝 (x?.y, fn?.())
  • use strict 불필요 (TypeScript 모듈 모드에서 자동 적용됨)

TC39 GitHub에서 최신 제안을 확인하라. TypeScript 팀은 Stage 3 제안을 빠르게 구현한다.

아이템 80: @ts-check와 JSDoc으로 TypeScript 실험하기

.ts로 전환하기 전에 기존 .js 파일에 타입 검사를 실험해 볼 수 있다.

// @ts-check
const person = { first: 'Grace', last: 'Hopper' };
2 * person.first;
// 오류: The right-hand side of an arithmetic operation must be of type 'number'

@ts-check에서 자주 발생하는 에러 유형

1. 선언되지 않은 전역 변수

// types.d.ts 파일로 전역 선언
interface UserData { firstName: string; lastName: string; }
declare let user: UserData;

2. 알 수 없는 라이브러리

npm install --save-dev @types/jquery

3. DOM 관련 오류

  • HTMLInputElementvalue 속성을 가지므로 타입 단언이 필요할 수 있다.
// @ts-check
// JSDoc으로 타입 단언
const ageEl = /** @type {HTMLInputElement} */ (document.getElementById('age'));
ageEl.value = '12'; // OK

4. 부정확한 JSDoc

  • @ts-check 활성화 시 기존 JSDoc이 검증되므로, 잘못된 주석을 수정해야 한다.

JSDoc으로 타입 추가하기

// @ts-check
/**
 * @param {number} val
 */
function double(val) {
  return 2 * val;
}
double('trouble');
// 오류: Argument of type 'string' is not assignable to parameter of type 'number'

@ts-check의 진정한 가치는 조직적 활용이다. 마이그레이션의 걸림돌과 난이도를 사전에 파악하고 경영진에게 설득할 수 있다. 그러나 JSDoc으로 완벽한 타입 주석을 달려 하지 말 것. 목표는 .ts로 전환하는 것이다.

아이템 81: allowJs로 TypeScript와 JavaScript 혼용하기

  • 대규모 프로젝트는 한 번에 전환이 불가능하다. allowJs 컴파일러 옵션으로 .ts.js 파일이 서로 임포트할 수 있게 된다.
  • allowJs의 JavaScript 파일에 대한 타입 체크는 극히 관대하다(@ts-check 없으면 구문 오류만 감지).
// tsconfig.json
{
  "compilerOptions": {
    "allowJs": true
  }
}
// webpack.config.js
module.exports = {
  module: {
    rules: [{
      test: /\.tsx?$/,
      use: 'ts-loader',
      exclude: /node_modules/,
    }],
  },
};
  • 핵심 가치: 코드를 하나도 바꾸기 전에 빌드 체인에 TypeScript를 먼저 통합할 수 있다. 이후 모듈을 하나씩 변환하면서 테스트를 계속 실행할 수 있다.
  • Jest 등 테스트 도구도 ts-jest를 설치하거나, Node.js는 ts-node를 사용해 TypeScript를 지원하도록 설정한다.

아이템 82: 의존성 그래프를 따라 모듈 단위로 변환하기

변환 전략

  • 의존성 그래프의 아래에서 위로 변환한다. 다른 모듈에 의존하지 않는 유틸리티 코드부터 시작해야, 변환 후 그것을 임포트하는 모듈도 올바른 타입 정보를 받는다.
  • 가장 먼저 서드파티 모듈 타입 선언 (@types/*) 과 외부 API 호출의 타입을 추가한다.
  • 테스트 파일은 마지막에 변환한다. 프로덕션 코드는 테스트를 임포트하지 않으므로, 마이그레이션 내내 테스트를 그대로 실행하며 안전망으로 활용할 수 있다.
  • 변환 중 리팩터링 충동을 억제하라. 코드 악취는 기록해 두고 나중에 처리한다.
# 의존성 그래프 시각화
npx dependency-cruiser --output-type dot src | dot -T svg > dependency-graph.svg

변환 중 자주 만나는 오류

1. 선언되지 않은 클래스 멤버

// 변환 전 JavaScript
class Greeting {
  constructor(name) {
    this.greeting = 'Hello';  // 오류: 'greeting' 프로퍼티가 없음
    this.name = name;
  }
  greet() { return `${this.greeting} ${this.name}`; }
}

// 에디터 "Add all missing members" 퀵 픽스 적용 후
class Greeting {
  greeting: string;
  name: any;  // any를 적절한 타입으로 수정
  constructor(name: string) {
    this.greeting = 'Hello';
    this.name = name;
  }
  greet(): string { return `${this.greeting} ${this.name}`; }
}

2. 변경되는 타입의 값

// 오류 발생
const state = {};
state.name = 'New York';    // 오류: 'name' 프로퍼티가 없음
state.capital = 'Albany';   // 오류: 'capital' 프로퍼티가 없음

// 해결책 1: 한 번에 객체 생성
const state = { name: 'New York', capital: 'Albany' };

// 해결책 2: 타입 단언 (임시방편, 나중에 리팩터링)
interface State { name: string; capital: string; }
const state = {} as State;
state.name = 'New York'; // OK
state.capital = 'Albany'; // OK

3. JSDoc에서 TypeScript로 전환 시 타입 안전성 손실

// @ts-check 적용 JavaScript: 오류 발생
/** @param {number} num */
function double(num) { return 2 * num; }
double('trouble'); // 오류

// TypeScript로 변환 시 @ts-check와 JSDoc이 무효화됨
/** @param {number} num */
function double(num) { return 2 * num; } // noImplicitAny 없으면 num: any, 오류 없음!
  • 변환 시 에디터의 “Copy JSDoc types to TypeScript type annotations” 퀵 픽스를 활용하고, 이후 JSDoc의 타입 부분은 제거하라.

아이템 83: noImplicitAny 활성화 전까지 마이그레이션 완료로 보지 않기

  • .ts로 전환 완료는 마이그레이션의 끝이 아니다. noImplicitAny 없이는 타입 선언의 실수가 은폐될 수 있다.
// noImplicitAny 없이: 실수가 숨겨짐
class Chart {
  indices: number; // number인데 실제로는 배열이었음

  getRanges() {
    for (const r of this.indices) {
      const low = r;  // const low: any (오류 없음!)
      const high = r; // const high: any
    }
  }
}

// noImplicitAny 활성화 후: 실수가 드러남
// Element implicitly has an 'any' type because expression of type
// 'number' can't be used to index type 'Number'.

noImplicitAny 활성화 전략

  1. 로컬에서 먼저 활성화해 오류 수를 파악한다.
  2. 오류를 의존성 그래프 위쪽부터 수정한다.
  3. tsconfig.json 변경 없이 타입 수정 커밋을 누적하다가 오류가 0이 되면 활성화 커밋을 올린다.
  4. strict: true로의 전환은 팀이 TypeScript에 익숙해진 후에 단계적으로 추진한다.
// 마이그레이션 완료 기준
{
  "compilerOptions": {
    "noImplicitAny": true
    // 이것이 활성화되어야 진짜 마이그레이션 완료
  }
}

최종 원칙: noImplicitAny가 활성화되지 않으면 TypeScript 마이그레이션은 미완성이다. 느슨한 타입 체크는 실제 타입 선언의 실수를 숨길 수 있다. 팀이 TypeScript에 익숙해질 기회를 주면서 점진적으로 엄격성을 높여가라.