티스토리 뷰

Vue Generic에서 keyof T 안전하게 사용하는 법 (v-for의 key 문제 해결)

Vue에서 Generic 컴포넌트를 만들다 보면 object[]를 Props로 받아서 순환하게 되는 경우가 종종있다.
이때 각 항목을 고유하게 식별하기 위해 v-for:keyitem[itemKey]와 같이 접근하게 되는데, 이런 코드에서 아래와 같은 TypeScript 오류를 마주칠 수 있다.

⚠️ 문제 상황

<script setup lang="ts" generic="T extends object">
interface Props<T extends object> {
    items: T[]
    itemKey: keyof T
}

const props = defineProps<Props<T>>()
</script>

<template>
    <div>
        <span v-for="item in items" :key="item[itemKey]"></span>
    </div>
</template>

위 코드에서 아래와 같은 에러가 발생.

Type '[{ type: PropType<keyof T>; required: true; }]' extends [Prop<infer V, infer D>] ? unknown extends V ? IfAny<V, V, D> : V : { type: PropType<keyof T>; required: true; }' cannot be used to index type 'T'.ts(2536)

📌 왜 이런 문제가 발생할까?

이 문제는 template 내부에서 TypeScript가 generic한 key 접근을 타입 안정적으로 보장할 수 없기 때문.

  • setup() 내부에서는 item[props.itemKey] 접근이 정상 작동.
  • 그러나 template 내부에서는 itemKey의 타입이 keyof T라고 해도, T[itemKey]PropertyKey가 아닌 불확정 타입으로 간주됩니다.

즉, 런타임에서 안전하게 v-forkey로 사용할 수 있을지 컴파일러가 확정할 수 없다는것.

✅ 최초 해결 시도

StackOverflow에서는 아래와 같이 제안하였다.

defineProps<{
  items: Array<{ [key in keyof T]: T[key] extends PropertyKey ? T[key] : never }>
  itemKey: keyof T
}>()

즉, items 배열을 Mapped Type + 조건부 타입을 활용해서 모든 속성이 PropertyKey일 때만 받도록 제한하는 방식.

하지만 이 방식은 다음과 같은 단점이 존재하였다.

const items = ref([
  { id: 1, date: new Date() },
  { id: 2, date: new Date() },
])

위처럼 Date 타입이 포함된 객체는 허용되지 않는다.
왜냐하면 DatePropertyKey가 아니기 때문..
의도했던건 단지 key로 사용할 필드만 PropertyKey면 되는 것인데 말이다.

💡 안전하고 유연한 해결 방법

1. setup() 영역에서 getItemKey() 함수 정의

function getItemKey(item: T) {
  return item[props.itemKey] as PropertyKey;
}

2. template에서는 이렇게 사용

<span v-for="item in items" :key="getItemKey(item)"></span>
  • as PropertyKey로 타입 단언하여 Vue가 받아들이도록 함
  • Vue template 내부에서는 타입 추론이 불완전하므로 setup() 내부에서 제어하는 것이 더 안전

🔐 유틸 타입으로 itemKey를 안전하게 제한하기

type ValidObjectKey<T> = {
  [K in keyof T]: T[K] extends PropertyKey ? K : never
}[keyof T]

이 타입은 다음과 같은 방식으로 동작.

type MyObj = {
  a: string
  b: number
  c: () => void
  d: object
}

type Result = { [K in keyof MyObj]: MyObj[K] extends PropertyKey ? K : never }
// 결과 타입 👇
// { a: "a"; b: "b"; c: never; d: never; }

type Result2 = Result[keyof MyObj]
// 결과 타입 👇
// "a" | "b"

즉, 객체의 key 중에서 value가 PropertyKey (string | number | symbol) 인 key만 추려내는 유틸 타입이다.

조건부 타입으로 never 타입이 된 key들은 indexed Acces Type으로 접근시 제외되어 위와 같은 결과가 나온다.

🎯 최종 정리된 코드

<script setup lang="ts" generic="T extends object">
interface Props<T extends object> {
  items: T[]
  itemKey: ValidObjectKey<T>
}

const props = defineProps<Props<T>>()

function getItemKey(item: T) {
  return item[props.itemKey] as PropertyKey
}
</script>

<template>
  <div>
    <span v-for="item in items" :key="getItemKey(item)">
      {{ item[props.itemKey] }}
    </span>
  </div>
</template>
  • ValidObjectKey<T>를 통해 itemKey의 값이 PropertyKey인 키만 허용
  • setup() 내에서 getItemKey()를 정의하고 타입 단언하여 template에서 안전하게 사용

🏁 마치며

그동안 template 내부에서 generic key 접근을 타입 단언으로 임시방편 처리했었다면, 이번 기회에 Mapped Type, Conditional Type, Indexed Access Type을 활용해 정확하고 안전하게 제약을 거는 방법을 익힐 수 있는
좋은 기회가 되었다.

이 글이 Vue + TypeScript를 사용하는 다른 개발자분들에게도 도움이 되었길 바랍니다 😄

댓글