- Published on
TS 5.5에서 const인데 narrowing 안될 때 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
TypeScript를 쓰다 보면 “const로 선언했으니 값이 고정되고 타입도 자동으로 좁혀질 것”이라고 기대하기 쉽습니다. 그런데 TS 5.5 환경에서 특히 아래 같은 상황을 만나면, const인데도 타입 narrowing이 안 되거나(혹은 너무 늦게/약하게) 동작해 코드가 지저분해집니다.
const x = { ... }인데x.kind가"A" | "B"로 남아 분기에서 좁혀지지 않음const key = "foo"인데 객체 인덱싱에서key가string취급됨const로 만든 배열/튜플인데 요소 타입이 넓어져"a" | "b"가 아니라string이 됨- 함수 경계(파라미터/리턴)나 제네릭을 거치며 리터럴이
widening되어 narrowing이 깨짐
이 글에서는 TS 5.5에서 자주 보이는 “const인데 narrowing 안 됨”의 원인을 유형별로 나누고, 가장 실용적인 해결책을 패턴으로 정리합니다. 객체 검증과 리터럴 고정에 유용한 satisfies도 함께 다룹니다(관련 글: TS 5.x satisfies로 타입 안전 유지하며 객체 검증).
1) 핵심 원인: const는 “재할당 불가”일 뿐, “리터럴 타입 고정”은 별개
const는 변수 바인딩 재할당을 막습니다. 하지만 타입 시스템에서 리터럴 타입이 유지될지(예: "A"), 넓어질지(예: string)는 컨텍스트 타입(contextual typing), 추론 위치, 제네릭 경계, 명시적 타입 주석 등에 의해 결정됩니다.
즉, 아래 두 개는 다릅니다.
- 런타임:
const라서 바뀌지 않음 - 타입: 리터럴이 유지될지/확장될지(= narrowing이 가능한 형태인지)
따라서 “const인데 narrowing이 안 된다”는 말은 보통 이미 타입이 넓어져(widened)버려서 분기에서 더 좁힐 재료가 없다는 뜻입니다.
2) 가장 흔한 케이스: 타입 주석이 리터럴을 넓혀버림
문제 예시: const + 명시적 타입이 유니온을 깨뜨림
type Event =
| { type: "created"; id: string }
| { type: "deleted"; id: string };
// 흔한 실수: 객체에 Event로 타입 주석을 달아버림
const e: Event = { type: "created", id: "1" };
if (e.type === "created") {
// 여기서는 괜찮아 보이지만, 다른 필드 설계가 복잡해지면
// 리터럴 유지/검증이 애매해지고 추론이 꼬이기 쉬움
}
이 케이스 자체는 narrowing이 되기도 하지만, 실제 코드에서는 다음처럼 더 복잡한 형태에서 문제가 터집니다.
type외에 여러 필드가 조건부로 존재- 객체를 다른 함수로 넘기며 타입이
Event로 “고정” as Event같은 단언으로 검증을 건너뜀
해결: satisfies로 “검증만” 하고 리터럴은 유지
type Event =
| { type: "created"; id: string; payload: { name: string } }
| { type: "deleted"; id: string; reason: "spam" | "user" };
const e = {
type: "created",
id: "1",
payload: { name: "alice" },
} satisfies Event;
// e.type은 "created" 리터럴로 유지됨
if (e.type === "created") {
e.payload.name; // OK
// e.reason; // Property 'reason' does not exist
}
satisfies는 “이 값이 Event를 만족하는지”만 검사하고, 변수의 구체적인 리터럴 타입은 유지시킵니다. TS 5.5에서도 이 패턴이 narrowing 안정성을 크게 올려줍니다.
3) as const가 필요한 순간: 객체/배열 내부 리터럴이 넓어지는 경우
문제 예시: 배열이 string[]으로 넓어짐
const roles = ["admin", "user"]; // string[] 로 추론될 수 있음
function hasRole(r: "admin" | "user") {}
// hasRole(roles[0]); // string 이라서 에러
해결 1: as const로 튜플/리터럴 고정
const roles = ["admin", "user"] as const;
// readonly ["admin", "user"]
type Role = (typeof roles)[number]; // "admin" | "user"
function hasRole(r: Role) {}
hasRole(roles[0]); // OK
해결 2: TS 5.x의 satisfies로 요소 타입을 제한
const roles = ["admin", "user"] satisfies Array<"admin" | "user">;
// roles는 여전히 string literal들을 유지할 수 있고,
// 동시에 배열 요소가 지정 유니온 밖이면 컴파일 에러
as const는 값 전체를 깊게 readonly로 만들기 때문에(의도치 않은 불변화) 부담이 있을 수 있습니다. 그럴 땐 satisfies가 더 부드러운 선택입니다.
4) “const인데 narrowing 안 됨”의 실전 1번: 객체 인덱싱에서 키가 string으로 취급
문제 예시: 키가 리터럴인데도 인덱싱 결과가 넓음
const dict = {
foo: { kind: "A" as const, value: 1 },
bar: { kind: "B" as const, value: 2 },
};
const key = "foo"; // "foo"로 추론되기도 하지만, 상황에 따라 string으로 widen
const item = dict[key];
// item이 {kind:"A"}로 안 좁혀지고, union 또는 넓은 타입이 될 수 있음
이건 대개 key가 함수 경계/제네릭/타입 주석 때문에 string으로 넓어지면서 발생합니다.
해결: 키를 keyof로 묶거나 제네릭으로 리터럴을 보존
const dict = {
foo: { kind: "A" as const, value: 1 },
bar: { kind: "B" as const, value: 2 },
};
type Dict = typeof dict;
type Key = keyof Dict; // "foo" | "bar"
function getItem<K extends Key>(k: K) {
return dict[k];
}
const itemFoo = getItem("foo");
// itemFoo.kind === "A"
포인트는 K extends keyof Dict로 리터럴 키를 제네릭으로 캡처해 widening을 막는 것입니다.
5) TS 5.5에서 자주 보이는 함정: 조건문이 “값 비교”가 아니라 “타입 비교 재료”가 부족한 상태
문제 예시: discriminator가 아닌 필드로 분기
type Res =
| { ok: true; data: { id: string } }
| { ok: false; error: { code: "E1" | "E2" } };
const res: Res = Math.random() > 0.5
? { ok: true, data: { id: "1" } }
: { ok: false, error: { code: "E1" } };
if (res.ok) {
res.data.id; // OK
} else {
res.error.code; // OK
}
이건 잘 됩니다. 하지만 discriminator가 불명확하거나 optional/nullable로 설계되면 narrowing이 약해집니다.
문제 예시: optional 필드 기반 분기(약한 narrowing)
type Res2 =
| { data?: { id: string }; error: { code: string } }
| { data: { id: string }; error?: { code: string } };
declare const r: Res2;
if (r.data) {
// r.data는 좁혀지지만, r.error가 어떤지 확정하기 어려워짐
}
해결: discriminator(태그) 필드를 명확히 두기
type Res3 =
| { type: "success"; data: { id: string } }
| { type: "failure"; error: { code: string } };
declare const r: Res3;
if (r.type === "success") {
r.data.id;
} else {
r.error.code;
}
TS의 narrowing은 “명확한 판별 기준”이 있을 때 가장 강합니다. const로 값이 고정돼도, 타입이 판별 가능한 구조가 아니면 narrowing이 기대만큼 안 됩니다.
6) 제네릭/함수 경계에서 리터럴이 사라질 때: const 제네릭 또는 오버로드 활용
문제 예시: 함수 파라미터에서 리터럴이 widen
function wrap<T>(value: T) {
return { value };
}
const x = wrap("created");
// x.value가 "created"가 아니라 string으로 보이는 상황이 생길 수 있음(문맥/제약에 따라)
해결 1: const type parameter(상수 제네릭) 사용
function wrap<const T>(value: T) {
return { value };
}
const x = wrap("created");
// x.value: "created"
const 제네릭은 인수의 리터럴 타입을 더 강하게 보존하는 데 유용합니다. TS 5.x에서 실전 체감이 큰 기능입니다.
해결 2: 오버로드로 호출 시그니처를 분리
function wrap(value: "created"): { value: "created" };
function wrap(value: "deleted"): { value: "deleted" };
function wrap(value: string) {
return { value };
}
const a = wrap("created");
// a.value: "created"
오버로드는 번거롭지만, 외부 공개 API에서 리터럴 보존이 매우 중요할 때 선택할 만합니다.
7) 타입 가드로 강제 narrowing: 마지막 보루지만 가장 확실
TS가 구조적으로 좁히기 어려운 케이스(외부 입력, 런타임 파싱 결과, unknown)에서는 사용자 정의 타입 가드가 가장 명확합니다.
type Created = { type: "created"; id: string };
type Deleted = { type: "deleted"; id: string };
type Event = Created | Deleted;
function isCreated(e: Event): e is Created {
return e.type === "created";
}
declare const e: Event;
if (isCreated(e)) {
// e: Created
e.id;
} else {
// e: Deleted
e.id;
}
특히 JSON 파싱 이후에는 unknown -> validation -> narrowing 흐름이 필요합니다. 이때 satisfies는 컴파일 타임 도구라 런타임 입력에는 직접 적용되지 않는다는 점도 기억해야 합니다.
8) 빠른 체크리스트: TS 5.5에서 const narrowing이 안 될 때 이렇게 점검
- 명시적 타입 주석이 리터럴을 넓히고 있지 않은가?
- 가능하면
: SomeUnion대신satisfies SomeUnion고려
- 가능하면
- 배열/객체 리터럴이 widen 되었나?
as const또는satisfies로 리터럴 유지
- 키 인덱싱에서 키 타입이 string으로 넓어졌나?
keyof+ 제네릭K extends keyof T패턴
- 분기 기준이 discriminator(태그)인가?
- optional 필드 기반 분기 대신
type: "..."같은 태그 설계
- optional 필드 기반 분기 대신
- 함수/제네릭 경계에서 리터럴이 사라지나?
function f<const T>(arg: T)형태로 보존
- 외부 입력/unknown인가?
- 타입 가드/런타임 검증으로 확실히 좁히기
결론
TS 5.5에서 “const인데 narrowing이 안 된다”는 문제는 대부분 const 자체가 아니라 리터럴 타입이 widen되는 지점(타입 주석, 함수 경계, 인덱싱, 애매한 유니온 설계)에 원인이 있습니다. 해결은 생각보다 단순한 편입니다.
- 객체/배열 리터럴은
as const또는satisfies로 리터럴을 지켜라 - 키 인덱싱은
keyof+ 제네릭으로 리터럴 키를 캡처하라 - 유니온은 discriminator(태그) 중심으로 설계하라
- 제네릭 함수는
consttype parameter로 리터럴 보존을 강화하라
특히 satisfies는 “검증은 하되 타입은 과하게 고정하지 않는” 균형점이라, TS 5.5에서 narrowing 문제를 줄이는 데 매우 효과적입니다. 더 자세한 satisfies 활용 패턴은 TS 5.x satisfies로 타입 안전 유지하며 객체 검증도 함께 참고하면 좋습니다.