TypeScript
제네릭, 좁히기와 불가능한 상태
제네릭으로 입력과 출력 사이의 타입 관계를 보존하고, 제어 흐름 기반의 타입 좁히기와 never를 통해 빠짐없는 분기 처리를 완성합니다.
이럴 때 필요해요
any를 쓰지 않고도 재사용 가능한 유틸리티 함수를 만들고, 새로운 상태가 추가되었을 때 처리 누락을 컴파일 단계에서 즉시 발견할 수 있습니다.
어떻게 동작하나요?
배열의 첫 번째 요소를 꺼내 주는 first(items) 함수를 만들 때 인자 타입을 any[]로 적으면, 숫자 배열을 넣어도 반환 타입이 any가 되어 이후 자동완성과 타입 검사가 모두 풀려 버립니다. 이때 타입 매개변수 <T>를 사용하는 제네릭(Generics)으로 function first<T>(items: readonly T[]): T | undefined라고 선언하면, 숫자 배열을 넣었을 때는 number | undefined, 문자열 배열을 넣었을 때는 string | undefined로 입력과 출력 사이의 타입 관계가 그대로 보존됩니다. 또한 Partial<T>이나 Pick<T, K> 같은 내장 유틸리티 타입을 활용하면 기존 타입에서 필요한 형태만 안전하게 파생시킬 수 있습니다.
유니온 타입처럼 여러 가능성이 열려 있는 값을 다룰 때는 typeof, instanceof, in 연산자나 판별 필드(kind) 비교 같은 런타임 조건문을 사용해 가능한 타입 후보를 줄여 나갑니다. 이를 타입 좁히기(Narrowing)라고 합니다. 반면 타입 단언(as)이나 잘못 구현된 사용자 정의 타입 가드(x is T)는 실제 값과 달라도 컴파일러가 논리 오류를 대신 증명해 주지 못하므로, 실제 런타임 조건과 타입이 일치하도록 작성해야 합니다.
조건 분기를 끝까지 좁혀서 더 이상 남을 수 있는 값의 후보가 하나도 없을 때 그 위치의 타입은 공집합 타입인 never가 됩니다. 값을 반환하지 않고 정상 종료하는 함수의 void와 달리, never는 아예 도달할 수 없거나 발생할 수 없는 상태를 뜻합니다. 아래 예제처럼 switch 문의 default에서 result를 never 타입 인자로 받는 assertNever에 넘겨 두면, 나중에 Result 유니온에 새로운 상태가 추가되었는데 switch 분기를 빠뜨렸을 때 컴파일 에러로 즉시 알려 줍니다.
코드 예제
동작을 설명하는 읽기 전용 예제입니다.
function first<T>(items: readonly T[]): T | undefined {
return items[0];
}
function assertNever(value: never): never {
throw new Error(`예상하지 못한 값: ${JSON.stringify(value)}`);
}
type Result =
| { kind: "loading" }
| { kind: "ready"; count: number }
| { kind: "error"; message: string };
function describe(result: Result): string {
switch (result.kind) {
case "loading": return "불러오는 중";
case "ready": return `${result.count}개`;
case "error": return result.message;
default: return assertNever(result);
}
}first는 입력 요소의 타입과 빈 배열 가능성을 보존합니다. describe는 kind로 타입을 좁힙니다. error case를 지우면 default의 result가 never가 아니므로 타입 오류가 발생합니다. 새 union 멤버를 추가해도 해당 case를 빠뜨리면 같은 방식으로 찾을 수 있습니다.
자주 헷갈리는 포인트
- 제네릭은 두 위치 이상의 타입 관계를 연결할 때 의미가 있으며, 근거 없이 반환 타입만 <T>로 열어 두는 것은 이름을 바꾼 any나 다름없습니다.
- 비동기 결과 타입인 Promise<number>는 성공 시의 값만 number로 보장할 뿐, 실패(reject) 시 던져지는 에러의 타입까지 제한하지는 않습니다.
내 말로 설명해 보기
판별 유니온을 다루는 switch 문에서 never 타입을 활용해 새로운 유니온 멤버의 처리 누락을 잡아내는 원리는 무엇인가요?
생각할 단서 모든 case를 처리한 뒤 result에 가능한 타입이 남는지 살펴보세요.
예시 답변 확인
설명에 들어갈 핵심
- 판별 필드 kind를 기준으로 각 case마다 가능한 유니온 후보를 하나씩 좁혀 나갑니다.
- 처리하지 않은 유니온 멤버가 남아 있으면 default 분기에서 assertNever(never 타입 요구)에 전달할 때 컴파일 오류가 발생합니다.
예시 답변
default에서 assertNever(result)를 호출합니다. error case를 생략하면 result에 error 멤버가 남아 never에 전달할 수 없습니다. 따라서 새 멤버를 추가할 때 처리 누락을 컴파일 단계에서 찾습니다.
문장을 그대로 외우기보다 예제의 값과 실행 흐름을 떠올리며 설명해 보세요.
함께 읽으면 좋은 개념
관련 면접 질문 4개
제네릭과 유틸리티 타입은 어떻게 타입 관계를 보존하나요?
제네릭(Generics)은 타입을 미리 하나로 고정하지 않고 타입 매개변수(<T>)로 열어 두었다가, 실제 호출 시점의 구체적인 타입 관계를 그대로 유지해 주는 기능입니다. 예를 들어 <T>(value: T): T는 입력과 출력 타입이 같다는 계약을 보존하며 extends로 필요한 프로퍼티 제약도 걸 수 있습니다. 또한 Partial<T>, Pick<T, K>, Omit<T, K> 같은 유틸리티 타입은 기존 타입을 바탕으로 부분 타입을 중복 선언 없이 안전하게 파생시킵니다.
꼬리질문과 참고자료 →타입 가드와 타입 단언은 어떤 보장이 다른가요?
타입 가드(Type Guard)는 typeof, instanceof, in 연산자나 판별 필드 비교처럼 실제 런타임에 실행되는 조건문을 통해 컴파일러가 분기 안의 타입을 안전하게 좁히도록(Narrowing) 만드는 방식입니다. 반면 타입 단언(as T)은 런타임 검사를 전혀 수행하지 않고 컴파일러에게 특정 타입으로 간주하라고 강제하는 표기입니다. 따라서 외부 API 응답처럼 불확실한 값은 as 단언 대신 실제 값을 확인하는 타입 가드로 검증해 다루어야 안전합니다.
꼬리질문과 참고자료 →Promise와 async 함수의 타입은 어떻게 표현하나요?
비동기 작업의 결과 타입은 제네릭을 사용한 Promise<T>로 표현하며, 여기서 T는 Promise가 성공적으로 이행(fulfilled)되었을 때 돌려주는 값의 타입입니다. async 함수는 언제나 Promise를 반환하므로, 함수 안에서 숫자(number)를 return하더라도 함수의 반환 타입 표기는 반드시 Promise<number>가 되어야 하며 반환값이 없을 때는 Promise<void>로 적습니다. 반대로 이 함수를 await하면 Promise 껍데기가 벗겨져 다시 T(예: number) 타입의 값을 얻게 됩니다.
꼬리질문과 참고자료 →