본문으로 건너뛰기
Advertisement

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의 차이

voidundefined는 비슷해 보이지만 중요한 차이가 있다.

// 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의 errorunknown 타입이 된다. 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하는 함수
voidundefined (+ null 비strict 시)제한적반환값 없는 함수, 콜백 반환 타입
비교 항목anyunknown
모든 타입 할당 가능OO
다른 타입에 직접 할당 가능OX (타입 가드 필요)
메서드/프로퍼티 직접 접근OX (타입 가드 필요)
타입 안전성없음있음
권장 여부최소화적극 활용

다음 장에서는 여러 값을 묶어 관리하는 배열과 튜플을 다룬다. readonly 배열로 불변성을 보장하는 방법, 튜플의 레이블과 가변 요소, as const로 리터럴 타입을 고정하는 패턴까지 살펴본다.

Advertisement