Published on

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

Authors

서론

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로 타입 안전 유지하며 객체 검증도 함께 참고하면 좋습니다.