Published on

TS 5.5에서 const인데 narrowing 안될 때 해결법

Authors
Binance registration banner

서론

TypeScript를 쓰다 보면 “const로 선언했으니 값이 고정되고 타입도 자동으로 좁혀질 것”이라고 기대하기 쉽습니다. 그런데 TS 5.5 환경에서 특히 아래 같은 상황을 만나면, const인데도 타입 narrowing이 안 되거나(혹은 너무 늦게/약하게) 동작해 코드가 지저분해집니다.

  • const x = { ... }인데 x.kind"A" | "B"로 남아 분기에서 좁혀지지 않음
  • const key = "foo"인데 객체 인덱싱에서 keystring 취급됨
  • 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이 안 될 때 이렇게 점검

  1. 명시적 타입 주석이 리터럴을 넓히고 있지 않은가?
    • 가능하면 : SomeUnion 대신 satisfies SomeUnion 고려
  2. 배열/객체 리터럴이 widen 되었나?
    • as const 또는 satisfies로 리터럴 유지
  3. 키 인덱싱에서 키 타입이 string으로 넓어졌나?
    • keyof + 제네릭 K extends keyof T 패턴
  4. 분기 기준이 discriminator(태그)인가?
    • optional 필드 기반 분기 대신 type: "..." 같은 태그 설계
  5. 함수/제네릭 경계에서 리터럴이 사라지나?
    • function f<const T>(arg: T) 형태로 보존
  6. 외부 입력/unknown인가?
    • 타입 가드/런타임 검증으로 확실히 좁히기

결론

TS 5.5에서 “const인데 narrowing이 안 된다”는 문제는 대부분 const 자체가 아니라 리터럴 타입이 widen되는 지점(타입 주석, 함수 경계, 인덱싱, 애매한 유니온 설계)에 원인이 있습니다. 해결은 생각보다 단순한 편입니다.

  • 객체/배열 리터럴은 as const 또는 satisfies로 리터럴을 지켜라
  • 키 인덱싱은 keyof + 제네릭으로 리터럴 키를 캡처하라
  • 유니온은 discriminator(태그) 중심으로 설계하라
  • 제네릭 함수는 const type parameter로 리터럴 보존을 강화하라

특히 satisfies는 “검증은 하되 타입은 과하게 고정하지 않는” 균형점이라, TS 5.5에서 narrowing 문제를 줄이는 데 매우 효과적입니다. 더 자세한 satisfies 활용 패턴은 TS 5.x satisfies로 타입 안전 유지하며 객체 검증도 함께 참고하면 좋습니다.