여섯 편을 거치며 타입을 가공하는 도구를 거의 다 봤습니다. 이번 마지막 글은 도구를 떠나서 — 좋은 타입과 과한 타입을 가르는 감각 을 정리합니다. any 와 unknown 의 진짜 차이부터 시작해, 자주 빠지는 안티패턴들과 그 자리에 어울리는 해결법까지요.
any vs unknown vs never — 세 위험 신호
가장 헷갈리는 세 타입을 한 번에 정리합시다.
| 타입 | 의미 | 안전한가 |
|---|---|---|
any | "이 값에 대한 검사를 모두 끄겠다" | ✗ 위험 |
unknown | "이 값이 무엇인지 모른다 — 좁히기 전엔 손도 못 댄다" | ✓ 안전 |
never | "이 값은 존재할 수 없다" | ✓ 의도적 |
any — 모든 안전망이 꺼지는 자리
function dangerous(x: any) {
return x.foo.bar.baz(); // 컴파일 통과 — 런타임에 무엇이든 일어남
}
dangerous(null); // 통과
dangerous(42); // 통과
dangerous('hello'); // 통과any 가 한 자리에 들어오면 그 자리에서부터 모든 자동완성과 타입 검사가 사라집니다. 또한 any 는 다른 모든 타입에 자유롭게 흘러들어갈 수 있어서, 한 군데의 any 가 다른 자리들의 안전망까지 무력화시킬 수 있어요.
any 를 써야 할 자리는 거의 없습니다. 새 코드에서 any 를 보면 거의 항상 더 좋은 답이 있어요.
unknown — any 의 안전한 대안
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에 멤버가 없다" 를 표현합니다.
// 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 의 효과를 봤습니다. 다시 정리하면 — 객체와 배열을 적은 그대로 리터럴 타입으로 고정합니다.
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에 추가된 도구. 타입 호환성은 검사하지만, 변수의 추론된 좁은 타입은 그대로 보존합니다.
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 정의 같은 자리. "이 모양이 어떤 인터페이스를 만족하면서도, 구체 키와 값은 정확히 알고 싶다" 가 만나는 자리예요.
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 은 컴파일러를 속이는 도구입니다. 컴파일은 통과하지만 런타임에는 검사가 없어요.
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 가 두세 개 쌓이면 읽고 수정하기 어려워집니다.
type Foo<T> = T extends string
? T extends `${infer A}-${infer B}`
? A extends 'admin'
? B extends `${number}`
? AdminId<B>
: never
: never
: never
: never;이런 모양이 나오면 보통 둘 중 하나입니다.
- 진짜로 타입 단계에서 표현해야 할 일 (라이브러리 작성 시 한정)
- 사실은 런타임 검증으로 더 잘 풀리는 일
대부분의 앱 코드는 2번입니다. 비싼 타입 트릭으로 푸는 것보다, 데이터 입구에서 zod 같은 도구로 검증하고 그 뒤로는 평범한 타입을 쓰는 게 거의 항상 읽기 좋아요.
4) 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-types 가 Function 사용을 막는 이유가 이거예요.
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[] 을 쓰면 안전망이 살아납니다.
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편을 거치며 정리한 도구들:
- keyof와 typeof — 타입을 끌어내기 (#1)
- Mapped types — 객체 타입을 통째로 변환 (#2)
- Conditional types와 infer — 분기와 추출 (#3)
- Template literal types — 문자열 타입 합성 (#4)
- Discriminated union과 타입 가드 — 안전한 모델링 (#5)
- 모듈과 .d.ts — 외부 타입 다루기 (#6)
- 실전 패턴과 안티패턴 — 좋은 감각 (이번 글)
이 시리즈가 다룬 건 거의 다 이미 익숙한 타입 위에 도구를 쌓는 일 이었습니다. 도구 자체보다 중요한 건 언제 어떤 도구를 꺼낼지 판단하는 감각이에요. 처음 만나는 문제 앞에서 "어, 이거 mapped type으로 풀 수 있겠다" 또는 "이건 그냥 평범한 type alias 면 충분해" 라는 판단이 빠르게 나오면, 시리즈의 목적은 다 한 셈입니다.
타입스크립트가 우리 일을 막는 게 아니라 함께 가는 동료가 되는 지점, 그게 결국 우리가 만나려고 했던 자리입니다.