타입스크립트 심화 #7 실전 패턴과 안티패턴

여섯 편을 거치며 타입을 가공하는 도구를 거의 다 봤습니다. 이번 마지막 글은 도구를 떠나서 — 좋은 타입과 과한 타입을 가르는 감각 을 정리합니다. anyunknown 의 진짜 차이부터 시작해, 자주 빠지는 안티패턴들과 그 자리에 어울리는 해결법까지요.

any vs unknown vs never — 세 위험 신호

가장 헷갈리는 세 타입을 한 번에 정리합시다.

타입의미안전한가
any"이 값에 대한 검사를 모두 끄겠다"✗ 위험
unknown"이 값이 무엇인지 모른다 — 좁히기 전엔 손도 못 댄다"✓ 안전
never"이 값은 존재할 수 없다"✓ 의도적

any — 모든 안전망이 꺼지는 자리

any의 위험
function dangerous(x: any) {
  return x.foo.bar.baz();   // 컴파일 통과 — 런타임에 무엇이든 일어남
}
 
dangerous(null);             // 통과
dangerous(42);               // 통과
dangerous('hello');          // 통과

any 가 한 자리에 들어오면 그 자리에서부터 모든 자동완성과 타입 검사가 사라집니다. 또한 any 는 다른 모든 타입에 자유롭게 흘러들어갈 수 있어서, 한 군데의 any 가 다른 자리들의 안전망까지 무력화시킬 수 있어요.

any 를 써야 할 자리는 거의 없습니다. 새 코드에서 any 를 보면 거의 항상 더 좋은 답이 있어요.

unknownany 의 안전한 대안

unknown 은 "무엇이 들어 있는지 모른다" 를 표현하지만, 좁히기 전에는 거의 모든 동작이 막힙니다.

unknown은 좁혀야 쓸 수 있음
function safe(x: unknown) {
  // x.foo;            ✗ unknown은 속성 접근 불가
  // x();              ✗ 호출 불가
  // x + 1;            ✗ 연산 불가
 
  if (typeof x === 'string') {
    console.log(x.length);   // OK — string으로 좁혀짐
  }
}

fetch().then(r => r.json()) 의 결과처럼 외부에서 들어오는 값은 항상 unknown 으로 받아 좁히는 게 안전합니다. 실전 #6 에서 다룬 패턴이 그거였죠.

never — 의도적으로 비움

never 는 "이 자리에 절대로 도달하지 않는다" 또는 "이 union에 멤버가 없다" 를 표현합니다.

never의 두 용도
// 1) exhaustiveness 검사 — [#5]에서 본 패턴
function area(s: Shape): number {
  switch (s.kind) {
    case 'circle': return ...;
    case 'square': return ...;
    default: {
      const _: never = s;   // 멤버 누락 시 ✗
      return 0;
    }
  }
}
 
// 2) 절대 반환하지 않는 함수
function fail(message: string): never {
  throw new Error(message);
}

never 가 등장한다는 것 자체는 보통 좋은 신호예요. 컴파일러가 무언가를 정확히 추적하고 있다는 뜻입니다.

as const — 가장 자주 안 써서 손해 보는 도구

#1 keyof와 typeof 에서 as const 의 효과를 봤습니다. 다시 정리하면 — 객체와 배열을 적은 그대로 리터럴 타입으로 고정합니다.

as const 효과 비교
const a = ['red', 'green', 'blue'];
//    ^ string[]
 
const b = ['red', 'green', 'blue'] as const;
//    ^ readonly ['red', 'green', 'blue']
 
type Color = (typeof b)[number];
// 'red' | 'green' | 'blue'

이게 없으면 위에서 본 keyof typeof OBJ 패턴이 다 무너져요. 데이터 한 곳에 정의하고 타입을 자동 생성하는 거의 모든 패턴이 as const 위에 서 있습니다.

satisfies — 타입 검사 통과 + 좁은 추론 둘 다

타입스크립트 4.9에 추가된 도구. 타입 호환성은 검사하지만, 변수의 추론된 좁은 타입은 그대로 보존합니다.

annotation vs satisfies
type Routes = Record<string, string>;
 
// annotation — 검사는 되지만 좁은 추론은 잃음
const a: Routes = {
  home: '/',
  about: '/about',
};
 
a.home;      // string
a.unknown;   // string  — Record<string, string>이라 어떤 키든 통과
 
// satisfies — 검사도 되고 좁은 추론도 살아남음
const b = {
  home: '/',
  about: '/about',
} satisfies Routes;
 
b.home;      // string
b.unknown;   // ✗ 'unknown'이라는 키 없음

차이가 핵심입니다. : Routes 로 적으면 변수 타입이 Routes 그대로 가 되어, 어떤 키든 통과해 버려요. satisfies Routes 는 "이 모양이 Routes 와 호환되는지만 검사하고, 변수의 실제 타입은 적은 그대로" 를 의미합니다.

어디에 쓰면 좋은가

설정 객체, 라우트 맵, action 정의 같은 자리. "이 모양이 어떤 인터페이스를 만족하면서도, 구체 키와 값은 정확히 알고 싶다" 가 만나는 자리예요.

액션 정의에서 satisfies
type ActionMap = Record<string, (state: State) => State>;
 
const actions = {
  increment: (s) => ({ ...s, count: s.count + 1 }),
  reset:     (s) => ({ ...s, count: 0 }),
} satisfies ActionMap;
 
// actions.increment 은 정확히 (s: State) => State
// actions.unknown 은 ✗

satisfies 는 처음 쓰면 어색하지만, 익숙해지면 타입 annotation을 거의 안 쓰게 만드는 도구예요.

안티패턴 — 자주 빠지는 자리들

이제 안티패턴을 보겠습니다. 매번 정답이 있는 건 아니지만, 이 모양이 보이면 한 번 멈춰서 다시 생각해 볼 가치가 있어요.

1) 모든 곳에 타입 명시

타입스크립트는 추론이 강력합니다. 모든 곳에 명시하면 추론 결과보다 좁은 타입이 적혀 정보가 사라지는 일이 흔해요.

과한 명시 — 정보가 사라짐
const items: string[] = ['apple', 'banana'];
// 추론은 string[] 이지만, 'apple' | 'banana' 가 더 정확할 수도
 
const status: string = 'idle';
// 'idle' 같은 좁은 추론을 잃음

함수 매개변수와 반환 타입처럼 외부 계약에 해당하는 자리만 명시하고, 변수 선언/콜백/추론 가능한 자리는 추론에 맡기는 게 보통 더 좋습니다.

2) as 캐스팅 남발

as Type컴파일러를 속이는 도구입니다. 컴파일은 통과하지만 런타임에는 검사가 없어요.

as 캐스팅의 함정
const data = (await res.json()) as User;
// 서버가 User 모양을 보냈는지 보장 없음
 
const id = parseInt(s) as UserId;
// 사실 number | NaN 일 수 있는데도 통과

대부분의 as 는 다음 중 하나로 바꿀 수 있습니다.

  • 값을 검증해 좁히는 타입 가드 (value is X)
  • assertion 함수 (asserts value is X)
  • zod 같은 스키마 로 검증
  • branded types + 가드로 출처 강제

DOM 조작에서 e.target as HTMLInputElement 같은 자리는 어쩔 수 없지만, 데이터 흐름에서 as 가 보이면 다시 생각해 보세요.

3) 거대한 conditional type 체인

타입 단계 if 가 두세 개 쌓이면 읽고 수정하기 어려워집니다.

과한 conditional
type Foo<T> = T extends string
  ? T extends `${infer A}-${infer B}`
    ? A extends 'admin'
      ? B extends `${number}`
        ? AdminId<B>
        : never
      : never
    : never
  : never;

이런 모양이 나오면 보통 둘 중 하나입니다.

  1. 진짜로 타입 단계에서 표현해야 할 일 (라이브러리 작성 시 한정)
  2. 사실은 런타임 검증으로 더 잘 풀리는 일

대부분의 앱 코드는 2번입니다. 비싼 타입 트릭으로 푸는 것보다, 데이터 입구에서 zod 같은 도구로 검증하고 그 뒤로는 평범한 타입을 쓰는 게 거의 항상 읽기 좋아요.

4) Function 타입

Function 자체를 타입으로 쓰는 코드를 종종 봅니다. 이게 거의 항상 함정이에요.

Function은 너무 넓다
function call(fn: Function) {
  return fn(1, 2, 3);    // 어떤 인자든 통과 — 안전하지 않음
}

대신 호출 시그니처를 명시하세요.

구체 시그니처
function call(fn: (...args: unknown[]) => unknown) {
  return fn(1, 2, 3);
}
 
// 또는 더 좁게
function call<T>(fn: (a: number, b: number) => T): T {
  return fn(1, 2);
}

타입스크립트 ESLint의 기본 규칙 @typescript-eslint/ban-typesFunction 사용을 막는 이유가 이거예요.

5) Object / {} 타입

Object{}거의 모든 값을 받습니다 (null/undefined 빼고).

{}는 거의 모든 값
function f(x: {}) {
  // x는 거의 모든 값 — null/undefined만 제외
  // 사실상 안전망이 없는 자리
}
 
f(42);
f('hello');
f({ id: 1 });
f(true);

"객체를 받겠다"는 의도였다면 Record<string, unknown> 이나 더 구체적인 모양을 쓰는 게 안전합니다.

6) 인덱스 시그니처 남발

{ [key: string]: any } 같은 모양을 쉽게 적습니다. 이러면 #1 에서 본 keyof 가 무력해져요.

인덱스 시그니처의 함정
type Bag = { [key: string]: any };
 
const bag: Bag = { name: 'curtis' };
bag.unknown;     // any — 어떤 키든 통과

가능하면 명시적 모양 으로, 동적 키가 정말 필요하면 Record<'a' | 'b' | 'c', V> 같이 키 union을 좁혀 주세요.

7) any[] 보다는 unknown[]

배열의 원소를 모를 때 any[] 대신 unknown[] 을 쓰면 안전망이 살아납니다.

unknown[]
function processAll(items: unknown[]) {
  for (const x of items) {
    if (typeof x === 'string') {
      console.log(x.toUpperCase());
    }
  }
}

배열 자체는 다룰 수 있지만, 원소를 쓰려면 좁혀야 해요. 이게 정상적인 안전 모드입니다.

좋은 타입의 기준 셋

마지막으로, "이 타입이 좋은가" 를 판단할 때 보는 세 가지.

1) 의도가 보이는가string 보다 Status = 'idle' | 'loading' | 'done' 이 더 많은 의도를 전달합니다. Email 브랜드 타입이 그냥 string 보다 더 정확해요.

2) 자동완성이 좋은가 — 에디터에서 . 을 찍었을 때 의미 있는 후보가 뜨는 게 좋은 타입의 신호. any 가 들어오면 자동완성이 빈약해져요.

3) 리팩터링 안전한가 — 필드 이름 바꾸거나 시그니처 바꿀 때, 영향받는 곳이 한 번에 빨간 줄로 표시되어야 합니다.

세 가지를 만족하는 타입은 자기 문서가 됩니다. 별도 주석/문서 의존도가 줄고, 코드 안에 정보가 모여요.

어디까지 할 거야 — 타입의 비용

마지막으로 trade-off 한 줄. 타입스크립트 트릭은 공짜가 아닙니다.

  • 컴파일러 시간이 길어짐
  • 동료가 읽기 어려워짐
  • 다음에 자기 자신이 수정하기 어려워짐

라이브러리 작성자가 아니라면, 앱 코드의 90% 는 평범한 타입으로 충분히 잘 동작합니다. 이 시리즈에서 본 도구들은 필요한 자리에서만 꺼내 쓰는 도구예요. 모든 곳에 conditional type 과 mapped type을 넣을 필요는 전혀 없습니다.

좋은 기준 — "이 타입이 비싼 만큼 가치를 돌려주는가?" 그 답이 yes일 때만 꺼내 쓰세요.

시리즈를 마치며

심화 시리즈 7편을 거치며 정리한 도구들:

  1. keyof와 typeof — 타입을 끌어내기 (#1)
  2. Mapped types — 객체 타입을 통째로 변환 (#2)
  3. Conditional types와 infer — 분기와 추출 (#3)
  4. Template literal types — 문자열 타입 합성 (#4)
  5. Discriminated union과 타입 가드 — 안전한 모델링 (#5)
  6. 모듈과 .d.ts — 외부 타입 다루기 (#6)
  7. 실전 패턴과 안티패턴 — 좋은 감각 (이번 글)

이 시리즈가 다룬 건 거의 다 이미 익숙한 타입 위에 도구를 쌓는 일 이었습니다. 도구 자체보다 중요한 건 언제 어떤 도구를 꺼낼지 판단하는 감각이에요. 처음 만나는 문제 앞에서 "어, 이거 mapped type으로 풀 수 있겠다" 또는 "이건 그냥 평범한 type alias 면 충분해" 라는 판단이 빠르게 나오면, 시리즈의 목적은 다 한 셈입니다.

타입스크립트가 우리 일을 막는 게 아니라 함께 가는 동료가 되는 지점, 그게 결국 우리가 만나려고 했던 자리입니다.