2.2 특수 타입
TypeScript에는 JavaScript에 존재하지 않는 네 가지 특수 타입이 있다: any, unknown, never, void. 이들은 각각 타입 시스템의 서로 다른 경계를 다룬다. any는 타입 안전성을 의도적으로 포기하고, unknown은 안전한 방식으로 모든 값을 수용하며, never는 도달할 수 없는 코드를 표현하고, void는 반환값이 없는 함수를 나타낸다. 이 네 타입을 정확히 이해하고 올바르게 사용하는 것이 견고한 TypeScript 코드의 기반이다.
any — 타입 안전성 포기
any는 TypeScript의 타입 검사를 해당 변수에 대해 완전히 끄는 탈출구(escape hatch)다. any 타입 변수에는 어떤 값이든 할당할 수 있고, 어떤 메서드나 프로퍼티든 접근할 수 있다. 컴파일러는 이를 일체 검사하지 않는다.
let value: any = 42;
value = "hello"; // OK
value = true; // OK
value = { x: 1 }; // OK
value = [1, 2, 3]; // OK
value.toUpperCase(); // 컴파일 오류 없음 — 런타임에 실패할 수 있다
value.nonExistentMethod(); // 컴파일 오류 없음 — 런타임에 TypeError
value[99].deep.call(); // 컴파일 오류 없음 — 런타임에 참사
any가 전파되는 방식
any의 가장 위험한 특성은 전파(spreading)다. any 타입 값을 다른 변수에 할당하면 그 변수도 any가 된다.
function parseData(raw: any) {
const name = raw.name; // name은 any
const age = raw.age + 1; // age도 any — 타입 안전성 완전 소멸
return { name, age }; // 반환 타입도 { name: any, age: any }
}
const result = parseData({ name: "Alice", age: 30 });
result.name.toUpperCase(); // 컴파일 오류 없음
result.name.nonExistent(); // 컴파일 오류 없음 — 런타임에 오류
어쩔 수 없이 사용하는 경우
현실에서 any를 완전히 피하기 어려운 상황이 있다.
// 1. 레거시 JS 라이브러리에 타입 정의(@types/*)가 없을 때
declare const legacyLib: any;
legacyLib.initialize({ debug: true });
// 2. 점진적 마이그레이션 — 방대한 JS 코드를 TS로 옮기는 중간 단계
// (장기적으로는 unknown으로 교체해야 한다)
function migrateOldFunction(data: any): any {
// TODO: 타입 정의 추가 예정
return data;
}
// 3. 동적 JSON 역직렬화 — 바로 unknown으로 대체 가능
const parsed = JSON.parse(rawString); // 반환 타입이 any
any 최소화 전략
// 전략 1: unknown으로 교체하고 타입 가드 추가
function process(value: unknown): string {
if (typeof value === "string") return value.toUpperCase();
if (typeof value === "number") return value.toString();
return String(value);
}
// 전략 2: 제네릭으로 대체하여 타입 안전성 유지
function identity<T>(value: T): T {
return value;
}
// 전략 3: ESLint @typescript-eslint/no-explicit-any 규칙으로 경고 설정
// eslint.config.js에서 활성화하면 코드베이스 전반의 any 사용을 추적할 수 있다
// 전략 4: noImplicitAny: true (tsconfig.json) — 암묵적 any 금지
// function bad(x) { return x; } // 오류: Parameter 'x' implicitly has an 'any' type
unknown — any의 안전한 대안
unknown은 "타입을 아직 모른다"는 의미다. any처럼 모든 값을 받을 수 있지만, 타입을 확정하기 전까지 그 값을 사용할 수 없다는 핵심 차이가 있다. 외부 데이터, API 응답, 사용자 입력처럼 타입을 미리 알 수 없는 상황에서 any 대신 반드시 unknown을 써야 한다.
let data: unknown = fetchFromApi();
// 직접 사용하면 컴파일 오류
// data.toUpperCase(); // 오류: Object is of type 'unknown'
// const len = data.length; // 오류: Object is of type 'unknown'
// data(); // 오류: Object is of type 'unknown'
// 타입 가드를 통과한 후에만 사용 가능
if (typeof data === "string") {
console.log(data.toUpperCase()); // OK — 이 블록에서 data는 string
}
if (Array.isArray(data)) {
console.log(data.length); // OK — 이 블록에서 data는 unknown[]
}
unknown vs any 핵심 차이
let anyVal: any = "hello";
let unknownVal: unknown = "hello";
// any는 타입 체크 없이 다른 타입에 할당 가능
let str1: string = anyVal; // OK — 위험!
// unknown은 타입 확인 없이 다른 타입에 할당 불가
let str2: string = unknownVal; // 오류: Type 'unknown' is not assignable to type 'string'
// 타입 가드 후에는 가능
if (typeof unknownVal === "string") {
let str3: string = unknownVal; // OK
}
타입 가드의 종류
unknown 타입을 좁히기 위한 다양한 타입 가드 패턴이 있다.
function narrow(value: unknown): void {
// typeof 가드
if (typeof value === "string") {
console.log(value.toUpperCase());
}
// instanceof 가드
if (value instanceof Date) {
console.log(value.toISOString());
}
// Array.isArray 가드
if (Array.isArray(value)) {
console.log(value.length);
}
// 사용자 정의 타입 가드
if (isUser(value)) {
console.log(value.name);
}
}
interface User {
id: number;
name: string;
}
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value &&
typeof (value as User).id === "number" &&
typeof (value as User).name === "string"
);
}
함수 반환값에 unknown 활용
// JSON.parse는 any를 반환하지만, 래퍼 함수로 unknown을 반환하도록 강제할 수 있다
function safeJsonParse(json: string): unknown {
try {
return JSON.parse(json);
} catch {
return null;
}
}
const parsed = safeJsonParse('{"name":"Alice","age":30}');
// 바로 사용 불가 — 타입 가드 필수
// parsed.name; // 오류
if (isUser(parsed)) {
console.log(parsed.name); // "Alice"
console.log(parsed.id); // undefined (런타임에서 없음) — 타입은 number이므로 주의
}
never — 도달 불가능한 코드
never는 절대 발생할 수 없는 값의 타입이다. 함수가 항상 예외를 던지거나 무한 루프를 실행해서 정상적으로 반환되지 않을 때, 또는 타입 좁히기 후 가능한 타입이 하나도 남지 않을 때 나타난다.
항상 예외를 던지는 함수
function fail(message: string): never {
throw new Error(message);
}
function assertUnreachable(x: never): never {
throw new Error(`도달 불가능한 코드에 도달함: ${JSON.stringify(x)}`);
}
무한 루프
function runForever(): never {
while (true) {
// 이벤트 루프나 서버 메인 루프
}
}
완전성 검사 (Exhaustive Check)
never의 가장 강력한 활용은 판별 유니온(discriminated union)의 완전성을 컴파일 타임에 검증하는 것이다. 새로운 케이스가 추가됐는데 switch문에 처리가 빠진 경우를 타입 오류로 잡아낼 수 있다.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return (shape.base * shape.height) / 2;
default:
// shape는 여기서 never여야 한다
// 만약 Shape에 새 타입이 추가됐는데 처리를 안 했다면 컴파일 오류 발생
return assertUnreachable(shape);
}
}
새로운 도형 "ellipse"가 추가된다면:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number }
| { kind: "ellipse"; rx: number; ry: number }; // 추가
// getArea의 default에서 shape는 이제 { kind: "ellipse"; rx: number; ry: number } 타입
// assertUnreachable(shape)에서 오류: Argument of type '{ kind: "ellipse"; ... }' is not
// assignable to parameter of type 'never'
// → "ellipse" 케이스를 처리하지 않았음을 컴파일러가 알려준다
never와 조건부 타입
never는 조건부 타입에서 특정 타입을 필터링하는 데도 쓰인다.
// never는 유니온에서 제거된다: string | never = string
type NonNullable<T> = T extends null | undefined ? never : T;
type A = NonNullable<string | null | undefined>; // string
type B = NonNullable<number | null>; // number
void — 반환값 없음
void는 주로 반환값이 없는 함수의 반환 타입으로 사용한다. JavaScript에서 명시적 반환문이 없는 함수는 암묵적으로 undefined를 반환하는데, TypeScript는 이 의도를 void로 표현한다.
function logMessage(message: string): void {
console.log(`[LOG] ${message}`);
// return; // OK — 아무것도 없는 return
// return undefined; // OK
// return "hello"; // 오류: Type 'string' is not assignable to type 'void'
}
void와 undefined의 차이
void와 undefined는 비슷해 보이지만 중요한 차이가 있다.
// void 반환 함수의 결과를 변수에 담을 수 있지만 타입은 void
const result: void = logMessage("test");
// result를 사용하는 것은 의미 없다
// 콜백 타입에서 void는 "반환값이 무시된다"는 의미다
type Callback = () => void;
const cb: Callback = () => "hello"; // OK! 반환값이 있어도 무시된다
const arr = [1, 2, 3];
arr.forEach((n) => n * 2); // forEach의 콜백 반환 타입이 void — 반환값 무시
함수 타입 선언 시 void와 undefined의 차이
// 반환 타입이 void인 함수 타입 — 반환값이 있어도 컴파일 허용
type VoidFn = () => void;
const voidFn: VoidFn = () => 42; // OK
// 반환 타입이 undefined인 함수 — 반드시 undefined를 반환해야 함
type UndefFn = () => undefined;
const undefFn: UndefFn = () => 42; // 오류: Type 'number' is not assignable to type 'undefined'
const undefFn2: UndefFn = () => undefined; // OK
이 차이 때문에 Array.prototype.forEach, Array.prototype.map의 콜백 타입은 void를 사용한다. 콜백 내부에서 무엇을 반환하든 무시하겠다는 의도다.
실전 예제 1: API 에러 처리에서 unknown 사용
외부 API를 호출할 때 응답 형태를 미리 알 수 없다. unknown과 타입 가드를 조합해 안전하게 처리하는 패턴이다.
// API 응답 형태 정의
interface ApiSuccessResponse<T> {
status: "success";
data: T;
}
interface ApiErrorResponse {
status: "error";
code: number;
message: string;
}
type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;
// 타입 가드
function isApiError(response: unknown): response is ApiErrorResponse {
return (
typeof response === "object" &&
response !== null &&
"status" in response &&
(response as ApiErrorResponse).status === "error" &&
"code" in response &&
"message" in response
);
}
function isApiSuccess<T>(
response: unknown,
dataValidator: (data: unknown) => data is T
): response is ApiSuccessResponse<T> {
return (
typeof response === "object" &&
response !== null &&
"status" in response &&
(response as ApiSuccessResponse<T>).status === "success" &&
"data" in response &&
dataValidator((response as ApiSuccessResponse<T>).data)
);
}
// 사용자 데이터 타입과 검증
interface UserData {
id: number;
name: string;
email: string;
}
function isUserData(data: unknown): data is UserData {
return (
typeof data === "object" &&
data !== null &&
typeof (data as UserData).id === "number" &&
typeof (data as UserData).name === "string" &&
typeof (data as UserData).email === "string"
);
}
// API 호출 함수 — 반환 타입을 unknown으로 강제
async function fetchUser(id: number): Promise<unknown> {
const response = await fetch(`/api/users/${id}`);
return response.json(); // json()은 Promise<any> 반환 — unknown으로 래핑
}
async function getUser(id: number): Promise<UserData> {
const raw = await fetchUser(id);
if (isApiError(raw)) {
throw new Error(`API 오류 ${raw.code}: ${raw.message}`);
}
if (isApiSuccess(raw, isUserData)) {
return raw.data;
}
throw new Error("예상치 못한 응답 형식");
}
// 실제 사용
async function main() {
try {
const user = await getUser(1);
console.log(`사용자: ${user.name} (${user.email})`);
} catch (error) {
// error는 unknown 타입 (TypeScript 4.0+)
if (error instanceof Error) {
console.error(`오류: ${error.message}`);
} else {
console.error("알 수 없는 오류");
}
}
}
실전 예제 2: 판별 유니온 완전성 검사
// 알림 시스템 — 여러 채널로 메시지를 보낸다
type Notification =
| { channel: "email"; to: string; subject: string; body: string }
| { channel: "sms"; to: string; message: string }
| { channel: "push"; deviceToken: string; title: string; body: string }
| { channel: "slack"; webhookUrl: string; text: string };
function assertNever(x: never): never {
throw new Error(`처리되지 않은 알림 채널: ${(x as { channel: string }).channel}`);
}
function sendNotification(notification: Notification): Promise<void> {
switch (notification.channel) {
case "email":
return sendEmail(notification.to, notification.subject, notification.body);
case "sms":
return sendSms(notification.to, notification.message);
case "push":
return sendPush(notification.deviceToken, notification.title, notification.body);
case "slack":
return sendSlack(notification.webhookUrl, notification.text);
default:
// 모든 케이스를 처리했다면 여기서 notification은 never
// 새 채널이 추가되면 컴파일 오류로 누락을 즉시 알 수 있다
return assertNever(notification);
}
}
// 스텁 구현
declare function sendEmail(to: string, subject: string, body: string): Promise<void>;
declare function sendSms(to: string, message: string): Promise<void>;
declare function sendPush(token: string, title: string, body: string): Promise<void>;
declare function sendSlack(url: string, text: string): Promise<void>;
고수 팁
팁 1: catch 블록의 error는 unknown으로 처리하라
TypeScript 4.0부터 useUnknownInCatchVariables: true 설정 시 catch의 error가 unknown 타입이 된다. strict: true에 포함된다.
function riskyOperation(): void {
try {
JSON.parse("invalid json");
} catch (error) {
// error는 unknown
if (error instanceof Error) {
console.error(`에러 메시지: ${error.message}`);
console.error(`스택: ${error.stack}`);
} else if (typeof error === "string") {
console.error(`문자열 에러: ${error}`);
} else {
console.error("알 수 없는 형태의 에러:", error);
}
}
}
팁 2: never를 이용한 타입 필터링
// 특정 타입을 유니온에서 제거하는 유틸리티 타입
type Exclude<T, U> = T extends U ? never : T;
type WithoutString = Exclude<string | number | boolean, string>;
// 결과: number | boolean
type NonNullable<T> = T extends null | undefined ? never : T;
type SafeString = NonNullable<string | null | undefined>;
// 결과: string
팁 3: any 사용 시 반드시 주석으로 이유를 남겨라
// 아래 형태로 any 사용 이유를 문서화하면 나중에 개선할 때 맥락을 파악하기 쉽다
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const legacyCallback: any = getLegacyHandler();
// TODO: @types/legacy-lib 패키지가 출시되면 제거할 것 (issue #123)
팁 4: unknown으로 역직렬화 파이프라인을 구성하라
function parseConfig(raw: string): AppConfig {
const parsed: unknown = JSON.parse(raw);
return validateConfig(parsed); // 검증 함수가 unknown → AppConfig 변환 담당
}
function validateConfig(raw: unknown): AppConfig {
if (!isAppConfig(raw)) {
throw new Error("유효하지 않은 설정 파일 형식");
}
return raw;
}
// isAppConfig는 zod, yup, io-ts 같은 런타임 검증 라이브러리로 생성하면 더 안전하다
declare function isAppConfig(value: unknown): value is AppConfig;
interface AppConfig { port: number; dbUrl: string; }
팁 5: void 반환 타입을 명시해 콜백의 의도를 드러내라
// 이벤트 핸들러는 반환값이 의미 없음을 void로 명시
type EventHandler<T extends Event> = (event: T) => void;
const onClick: EventHandler<MouseEvent> = (e) => {
e.preventDefault();
console.log(e.clientX, e.clientY);
// return "ignored"; // 반환값이 있어도 허용됨 — void의 의미
};
비교 표
| 타입 | 할당 가능한 값 | 사용 가능 여부 | 주요 용도 |
|---|---|---|---|
any | 모든 값 | 제한 없음 (타입 검사 끔) | 레거시 마이그레이션, 임시 탈출구 |
unknown | 모든 값 | 타입 가드 통과 후에만 | 외부 데이터, API 응답, catch 블록 |
never | 없음 | 불가 (값 자체가 존재하지 않음) | 완전성 검사, 항상 throw하는 함수 |
void | undefined (+ null 비strict 시) | 제한적 | 반환값 없는 함수, 콜백 반환 타입 |
| 비교 항목 | any | unknown |
|---|---|---|
| 모든 타입 할당 가능 | O | O |
| 다른 타입에 직접 할당 가능 | O | X (타입 가드 필요) |
| 메서드/프로퍼티 직접 접근 | O | X (타입 가드 필요) |
| 타입 안전성 | 없음 | 있음 |
| 권장 여부 | 최소화 | 적극 활용 |
다음 장에서는 여러 값을 묶어 관리하는 배열과 튜플을 다룬다. readonly 배열로 불변성을 보장하는 방법, 튜플의 레이블과 가변 요소, as const로 리터럴 타입을 고정하는 패턴까지 살펴본다.