들어가며
네이버 부스트캠프를 진행하던 중 한 리뷰어분께서 오픈소스 기여 모임을 공유해주셨다. 오픈소스 기여는 늘 멀게만 느껴졌는데, 이번 기회에 도전해보기 위해 지원했다.
처음에는 프로젝트를 선정하는 것부터 쉽지 않았다. 최근 commit이 몇 년 전인 경우도 있었고, 서로 먼저 PR을 작성했다며 치열한 경우도 있었다. 그렇게 2~3일을 소요하고 나서 나만의 최소 기준을 세웠다.
- 프레임워크나 라이브러리는 내가 관심 있거나 자주 접해봤을 것
- 해당 깃허브의 Contributing 가이드가 체계적으로 되어 있을 것
- 메인테이너의 최근 commit 또는 merge가 일주일 이내일 것
- 이슈가 최소 50개 이상일 것
이 기준을 충족하면서 평소 주로 사용하여 내부 원리가 궁금하던 typescript-eslint를 선정했다.
이슈를 고를 때는 AI 프롬프트로 추천받으려고 했으나 이미 PR이 올라와 있는 경우, 이슈 번호와 실제 내용이 불일치하는 경우, 수년 전에 등록된 이슈인 경우 등 다양한 이유로 선별하기 어려웠고, 결국 라벨과 최신 순 필터링을 통해 이슈를 직접 살펴보았다. 첫 기여이지만 내부 원리를 이해하며 코드를 수정하고 싶었기 때문에 소재가 분명한 fix를 위주로 찾아보았고, Claude Code를 활용해 코드 베이스를 파악하며 수정 방향이 뚜렷하게 보이는 이슈를 찾고자 했다.
그 중 !(non-null assertion)과 관련된 버그에 눈길이 갔다. !는 예전에 코드 흐름상 undefined가 될 수 없는데 TypeScript의 control flow analysis가 이를 추론하지 못하자 타입 에러를 제거하기 위해 사용했었다. 그 때 어느 리뷰어분께서 개인적으로 !는 타입을 정의하는 타입스크립트의 목적 자체를 회피하는 느낌이라 지양하고 있다고 리뷰해주셨고, 이에 동의하여 이후 팀 프로젝트를 할 때는 팀원들과 컨벤션을 맞추되, 되도록 사용하지 않는 방향으로 작업해왔다. 그래서인지 !의 필요성과 한계를 다루는 이슈가 더 와닿았고, 이를 선정하여 기여를 진행했다.
!(non-null assertion)란
!는 타입 시스템 전용 연산자다. 런타임 동작을 전혀 바꾸지 않고, 컴파일러에게만 "이 값은 null | undefined가 아니다"라고 단언하는 역할을 한다.
const value: string | undefined = getValue();
const assured: string = value!; // 런타임엔 그대로 undefined일 수 있음효과는 NonNullable<T>와 동일하다. T | null | undefined에서 null과 undefined를 제거해 T로 좁힌다.
type A = string | undefined;
type B = NonNullable<A>; // string
// value! 은 이와 동일한 효과따라서 !를 제거했을 때 TypeScript 컴파일 에러가 난다면, 그 assertion은 실제로 필요한 것이다. ESLint가 그 !를 "불필요하다"고 경고한다면 그건 false positive다.
이슈 분석
문제 상황
Issue #11559 — no-unnecessary-type-assertion 규칙이 실제로 필요한 !를 "불필요한 assertion"이라고 잘못 경고하는 false positive 버그다.
재현 코드
type Data<T> = { value?: T };
type ValueType<TData> = TData extends Data<infer T> ? T : never;
export const foo = <TData extends Data<any>>(data: TData) => {
const getValue = () => data.value as ValueType<TData> | undefined;
const value: ValueType<TData> = getValue()!; // ← 여기서 거짓 경고 발생
return value;
};| 항목 | 내용 |
|---|---|
| 예상 | (assertion이 필요하므로) 에러 없음 |
| 실제 | "This assertion is unnecessary since the receiver accepts the original type of the expression" 경고 |
| 핵심 | !를 제거하면 TypeScript 컴파일 에러 발생 → assertion이 실제로 필요함 |
!를 지우면 컴파일 에러가 나는데 ESLint가 지우라고 경고하는 상황이다.
왜 이런 문제가 발생할까?
이슈에 달린 팀 멤버 Josh-Cena의 분석이 핵심을 짚어주었다.

getConstrainedTypeAtLocation이 실제로 하는 일
no-unnecessary-type-assertion 규칙은 !가 불필요한지 판단하기 위해 getConstrainedTypeAtLocation을 사용한다. 이 함수는 제네릭 타입을 그대로 비교하지 않고, 해당 타입의 base constraint로 치환한 뒤 분석한다.
base constraint란 제네릭 타입 매개변수가 extends로 제한받는 상한 타입이다. 예를 들어,
function foo<T extends { a: string }>(arg: T) {}arg의 actual type은 T이지만, getConstrainedTypeAtLocation(arg)은 T 대신 { a: string }을 반환한다. 즉 T → T의 base constraint로 치환이 일어난다.
왜 이 과정에서 구조 정보가 사라지는가?
constraint는 "제네릭이 최소한 만족해야 하는 타입"이다. 하지만 실제 타입 인자가 무엇인지, control flow narrowing을 통해 얼마나 좁혀졌는지는 담지 않는다. 따라서 이 치환 과정에서 다음 정보들이 소실될 수 있다.
- 실제 타입 인자 정보
- union 분기 결과
- control flow narrowing 결과
결과적으로 제네릭의 현재 상태가 아니라, 상한선(upper bound) 으로 되돌아가게 된다.
Narrowing과 Widening
TypeScript는 조건문을 통해 타입을 좁힌다(narrowing).
if (!value) return;
// 이후 value는 nullable이 아닌 타입으로 좁혀진다반대로 더 구체적인 타입이 상위 타입으로 다시 확장되는 것을 widening이라고 한다. 이번 케이스에서는 다음 흐름이 발생했다.
제네릭 T (narrowed 상태)
↓
getConstrainedTypeAtLocation
↓
Base Constraint (더 일반적인 타입) — widening 발생
narrowing된 타입이 constraint로 치환되면서 widening되었고, 그 과정에서 nullable 여부 정보가 소실되었다.
ValueType<TData>가 any로 평탄화된 이유
constraint로 치환된 타입이 충분히 구체적이지 않아 ValueType<TData>는 결국 any로 귀결되었다.
infer는 조건부 타입(T extends ... ? ... : ...) 안에서 타입을 패턴 매칭으로 추출할 때 사용하는 키워드다.
type UnwrapPromise<T> = T extends Promise<infer U> ? U : never;위 예시에서 T가 Promise<string>이면 U는 string으로 추론된다. infer는 새로운 타입 변수를 선언하는 것이 아니라, 매칭이 성공했을 때 그 자리에 있는 타입을 추론해 바인딩하는 역할을 한다.
infer 자체가 any를 만드는 것은 아니다. TypeScript에서 any는 모든 조건부 타입을 통과하고, 조건부 타입의 결과도 any로 귀결된다. 즉 조건부 타입에 any가 들어오면 추론 결과도 any로 전파된다.
any가 되면 nullable 여부를 구분할 수 없어 규칙 입장에서는 더 이상 "nullable일 수 있는 제네릭 타입"이 아니라 단순히 any만 보이게 된다. 그 결과 !를 불필요하다고 잘못 판단하게 되고, false positive가 발생하게 된 것이다.
단순 수정이 어려운 이유
| 관점 | getTypeAtLocation | getConstrainedTypeAtLocation |
|---|---|---|
| 제네릭 처리 | 그대로 T 반환 | constraint로 치환 |
| 정보량 | 최소 (선언된 그대로) | 최대 (제약까지 펼침) |
| 안전성 기준 | 낙관적 | 보수적 |
getConstrainedTypeAtLocation을 getTypeAtLocation으로 바꾸면 해결될 것 같지만, 그러면 다른 케이스가 깨진다.
function foo<T extends string | undefined>(bar: T) {
return bar!; // 이 경우 경고가 사라져야 하는데 사라지지 않음
}이 경우 T의 actual type은 그냥 T이고, base constraint(string | undefined)를 봐야만 nullable 가능성을 알 수 있다. 따라서 두 타입 모두 확인해야 한다.
해결 방안
핵심 아이디어
"베이스 제약(base constraint)과 실제 타입(actual type) 둘 다 검사하되, 어느 쪽이든 표면적으로 nullable이면 보고하지 않기"
이 방법은 특정 케이스만 예외 처리하는 게 아니라 rule이 타입을 분석하는 방식 자체를 더 정확하게 만드는 것이기 때문에 팀 멤버 Josh-Cena의 아이디어를 수용했다.
첫 번째 시도 — unnecessaryAssertion 분기 수정
const constrainedType = getConstrainedTypeAtLocation(services, node.expression);
const actualType = services.getTypeAtLocation(node.expression);
const constrainedTypeIsNullable = isNullableType(constrainedType);
const actualTypeIsNullable = isNullableType(actualType);
if (!constrainedTypeIsNullable && !actualTypeIsNullable) {
if (
node.expression.type === AST_NODE_TYPES.Identifier &&
isPossiblyUsedBeforeAssigned(node.expression)
) {
return;
}
// 기존 코드: 불필요한 assertion으로 보고
}두 타입 모두 nullable하지 않을 때만 "불필요한 assertion"으로 보고하도록 변경했다. 어느 한쪽이라도 nullable이면 !가 필요할 수 있으므로 경고하지 않는다.
두 번째 문제 — contextuallyUnnecessary 분기
첫 번째 수정으로 unnecessaryAssertion 경고는 막았지만, contextuallyUnnecessary 경고가 여전히 발생했다.
흐름을 따라가보면 이렇다.
constrainedTypeIsNullable = true—>any는 nullable로 취급actualTypeIsNullable = true—>ValueType<TData> | undefined도 nullable- 둘 다 nullable이므로 첫 번째
if조건 불충족 → else 브랜치로 진입 - else 브랜치에서
contextualType이 존재하고, nullable 체크도 통과 → 여전히 에러 보고
문제의 핵심은 else 브랜치가 constrainedType과 actualType이 서로 다른 경우를 고려하지 않는다는 것이었다. constrainedType이 any이고 actualType이 타입 파라미터인 상황에서 TypeScript는 !를 요구하지만 규칙은 불필요하다고 판단하는 것이다.
최종 해결
else 브랜치에 두 타입이 다를 경우 조기 반환하는 로직을 추가했다.
두 타입이 다르다는 것은 제네릭 문맥에서 constraint 치환이 원래 타입 정보와 불일치하는 상황이다. 이때 규칙은 !의 필요성을 정확히 판단할 충분한 정보를 갖추지 못한 상태이므로, false positive를 방지하기 위해 보고하지 않도록 했다.
if (!constrainedTypeIsNullable && !actualTypeIsNullable) {
if (
node.expression.type === AST_NODE_TYPES.Identifier &&
isPossiblyUsedBeforeAssigned(node.expression)
) {
return;
}
// 기존 코드
} else {
if (constrainedType !== actualType) {
return; // 제네릭의 constraint와 actual type이 다르면 판단 보류
}
// 기존 코드: contextuallyUnnecessary 체크
}수정 전후의 타입 판단 흐름을 비교하면 이렇다.

PR 제출
PR #11967을 생성했다. typescript-eslint의 Contributing 가이드에 따라 PR 제목은 아래 형식을 따랐다.
fix(eslint-plugin): no-unnecessary-type-assertion false positive with inferred generic types
이슈에서 보고된 재현 코드를 valid 테스트 케이스로 추가하여, !를 제거했을 때 실제 TypeScript 컴파일러가 에러를 내는지 확인하는 자동화 테스트도 함께 구축했다.
마치며
해당 PR은 8.54.0 버전에 포함되어 정식으로 릴리즈되었다. 전 세계 TypeScript 개발자들이 매일 사용하는 도구의 버그를 직접 수정하고, 그 변경 사항이 실제 배포까지 이어졌다는 사실이 아직도 신기하다.
이번 기여는 단순히 버그 하나를 고친 경험을 넘어 TypeScript의 제네릭과 타입 추론이 내부적으로 어떤 방식으로 동작하는지 깊이 있게 이해하는 계기가 되었다. 특히 작은 문법 요소인 ! 하나를 둘러싼 문제였지만, 그 이면에는 TypeScript 컴파일러의 타입 체크 전략과 이를 해석하는 ESLint 규칙 구현 사이의 미묘한 간극이 존재하고 있었다.
겉보기에는 사소해 보이는 수정이었지만 언어 레벨의 타입 시스템과 정적 분석 도구가 상호작용하는 방식을 직접 다뤄볼 수 있었다는 점에서 기술적으로도, 개인적으로도 의미 있는 경험이었다.