본문 바로가기
front/vue

TS with Composition API

by juniKang 2022. 5. 9.

Typing Component Props

<script setup>을 사용하는 경우

<script setup>을 사용할 때, defineProps() 매크로가 아규먼트로 props types를 추론하는 일을 한다:

<script setup lang="ts">
const props = defineProps({
  foo: { type: String, required: true },
  bar: Number
})

props.foo // string
props.bar // number | undefined
</script>

이런 일은 "런타임 선언(runtime declaration)" 이라고 부른다. 왜냐하면, defineProps()로 넘기는 아규먼트들은 runtime props options으로 사용될 것이기 때문이다.

 

하지만, 보다 간단한(straightforward) 

제네릭 타입 아규먼트를 사용해서 순수한 타입들로 props를 정의하는 것이 보통 좀 더 간단하다:

<script setup lang="ts">
const props = defineProps<{
  foo: string
  bar?: number
}>()
</script>

이런 걸 "타입 기반 선언(type-based declaration)"이라고 칭한다. 컴파일러는 타입 아규먼트에 기반하여 동등한 런타입 옵션들을 추론하기 위해 최선을 다할 것이다. 이 경우에, 두번째 예시는 첫번째 예제와 완전히 똑같은 런타입 옵션들을 컴파일 할 것이다. 

 

타입 기반 선언이나 런타임 선언 둘 다 쓸 수 있다. 하지만 동시에 쓸 수는 없다.

 

props 타입들을 분리된 인터베이스로 옮길 수도 있다:

<script setup lang="ts">
interface Props {
  foo: string
  bar?: number
}

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

 

Syntax Limitations

올바른 런타임 코드를 생성하기 위해서, defineProps()의 제네릭 아규먼트는 다음 중 하나여야만 한다:

  • 오브젝트 리터럴 타입:
defineProps<{ /*... */ }>()

 

  • 같은 파일내에 있는 인터페이스나 오브젝트 리러럴 타입의 참조:
interface Props {/* ... */}

defineProps<Props>()

인터페이스나 오브젝트 리터럴 타입은 다른 파일로부터 imported된 타입의 참조를 담을 수 있으나, definePorps로 넘기는 제네릭 아규먼트는 imported 타입을 쓸 수 없다:

import { Props } from './other-file'

// NOT supported
defineProps<Props>()

그 이유는, 뷰 컴포넌트들이 독립적으로 컴파일 되고, 컴파일러가 소스타입을 분석하기 위해 현재 임포트된 파일을 크롤링(crawl) 하지 않기 때문입니다. 이 한계는 추후 릴리즈에서 제거될 수 있습니다.

 

 <script setup> 없이 사용

<script setup>을 사용하지 않을 때는, props 타입 추론을 하기 위해서 defineComponent()를 사용해야 한다. setup()으로 넘겨지는 props 객체의 타입은 props option에 의해 추론된다. 

import { defineComponent } from 'vue'

export default defineComponent({
  props: {
    message :String
  },
  setup(props) {
    props.message // <-- type: string
  }
})

컴포넌트 Emits에 타입적용하기(Typing Component Emits)

<script setup>에서, emit 함수는 런타임 선언 이나 타입 선언 둘다에서 타입을 지정할 수 있다:

<script setup lang="ts">
// runtime
const emit = defineEmits(['chnage', 'update'])

// type-based
const emit = defineEmits<{
  (e: 'change', id: number): void
  (e: 'update', value: string): void
}>()
</script>

타입 아규먼트는 콜 시그니쳐(Call Signatures) 로 타입 리터럴이 될 수 있다. 타입 리터럴은 emit function의 리턴 타입으로 사용될 것이다. 보듯이, 타입 선언은 emit된 이벤트들의 타입 규약에 대해 (over the type constranints) 훨씬 더 세분화된(finer-grained) 컨트롤을 제공한다.

 

<script setup>을 사용하지 않을 때는, defineComponent()는 setup context에 노출된 emit 함수에 대해(for ~) 허락된 이벤트를 추론할 수 있다:

import { defineComponent } from 'vue'

export defualt defineComponent({
  emits: ['change'],
  setup(props, { emit }) {
    emit('change') // <-- type check / auto-completion
  }
})

 

ref()에 타입 적용하기 (Typing ref())

Refs는 초기값에서(from the initial value) type을 추측한다:

import { ref } from 'vue'

// 추측된 타입: Ref<Number>
const year = ref(2020)

// => TS Error: Type 'string' is not assignable to type 'number'.
year.value = '2020'

때로 ref's 의 내부 값에 대해 특별히 복합적인 타입들이 필요할 수도 있다. 그럴 때 Ref type을 사용할 수 있다:

import { ref } from 'vue'
import type { Ref } from 'vue'

const year: Ref<string | number> = ref('2020')

year.value = 2020 // ok!

또는, 기본적으로 참조되는 값을 오버라이드 하기 위해 ref()를 호출할 때 제네릭 아규먼트로 넘길 수도 있다:

const year = ref<string | number>('2020')

year.value = 2020 // ok!

만약 제네릭 타입 아규먼트를 특정했지만 초기값을 빠뜨렸다면, 결과 타입은 undefined를 포함한 유니온 타입이 될 것이다:

// 추측되는 타입: Ref<number | undefined>
cosnt n = ref<number>()

reactive()와 타입스크립트 사용하기

reactive()는 또한 아규먼트에서(from) 타입을 암묵적으로 추론한다:

import { reactive } from 'vue'

// 추론한 타입: { title: string }
const book = reactive({ title: 'Vue 3 Guide' })

reactive 프로퍼티를 명시적으로 타입지정 하려면, 인터페이스를 사용할 수 있다: 

import { reactive } from 'vue'

interface Book {
  title: string
  year?: number
}

const book: Book = reactive({ title: 'Vue 3 Guide' })

coputed()와 타입스크립트 사용하기 (Typing computed())

computed()는 getter의 리턴타입으로 타입을 추측한다:

import { ref, computed } from 'vue'

const count = ref(0)

// 추측 타입: ComputedRef<number>
const double = computed(() => count.value * 2)

// => TS Error: Property 'split' does not exist on type 'number'
const result = double.value.split('')

제네릭 아규먼트를 통해 명시적인 타입을 특정할 수도 있다:

const double = computed<number>(() => {
  // type error if this doesn't return a number
})

이벤트 핸들러 타입 지정하기 (Typing Event Hadnlers)

네이티브 DOM 이벤트를 다룰 때, 핸들러에 정확히 넘긴 아규먼트를 타입지정하는 것은 유용할 수 있다. 아래 예제를 보자:

<script setup lang="ts">
function handleChange(event) {
  // 'event' 는 암묵적으로 'any' 타입이다.
  console.log(event.target.value)
}
</script>

<template>
  <input type="text" @change="handleChange" />
</template>

타입 어노테이션이 없으면, event 아규먼트는 암묵적으로 any 타입을 가질 것이다. tsconfig.json에서 "strict": true나 "moI,plicitAny":true를 사용하면, TS error가 나타날 것이다. 이벤트 핸들러의 아규먼트를 명시적으로 주석을 다는걸 추천한다. 추가로, event에 명시적으로 프로퍼티들을 형변환(cast)해야 할 수도 있다(may need to):

function handleChange(event: Event) {
  console.log((event.target as HTMLInputElement).value)

Provide / Injectuib 과 타입 스크립트 사용하기 ( Typing Provide / Inject )

Provide와 inject는 보통 별도의 컴포넌트에서 동작한다. injected values에 적절히 타입지정하기 위해서는, 뷰는 InjectionKey 인터페이스를 제공한다. 이 인터페이스는 Symbol을 상속하는 제네릭 타입이다. provider와 consumer 사이에서 injected value의 타입을 동기화하는데 사용될 수 있다(can be used to):

import { provide, inject } from 'vue'
import type { InjectionKey } from 'vue'

const  key = Symbol() as InjectionKey<string>

provide(key, 'foo') // string이 아닌 값을 제공하면 에러가 난다.

const foo = inject(key) // foo의 타입: string | undefined

별도의 파일에 injection key를 놓는 것을 추천한다. 그래야(so that) 여러개의 컴포넌트들에서 import 해서 사용할 수 있다.

 

string injection keys를 사용할 때는, injected value의 타입은 unknown 이고, 제네릭 타입 아규먼트를 통해 명시적으로 선언해야 한다(needs to be pp):

const foo = inject<string>('foo') // type: string | undefined

injected value는 여전히 undefined 일 수 있다. 왜냐하면, provider가 런타임에 이 값을 제공할 것이라는 보장이 없기 때문이다.

 

undefined 타입은 default value를 제공해서 제거할 수 있다:

const foo = inject<string>('foo', 'bar') // type: string

만약 값이 항상 제공된다고 확신한다면, 값을 강제 형변환할 수도 있다:

const foo = inject('foo') as string

Template Refs와 타입스크립트 사용하기(Typing Template Refs)

Template refs는 명시적인 제네릭 타입 아규먼트와 null의 초기값으로 만들어야져 한다:

<script setup lang="ts">
import { ref, onMounted } from 'vue'

const el = ref<HTMLInputeElement | null>(null)

onMounted(() => {
  el.value?.focus()
})
</script>

<template>
  <input ref="el" />
</template

엄격한 타입 세이프티를 위해서는, el.value에 접근할 때, 추가적인 chaining 이나 type guards를 사용할 필요가 있다. 컴포넌트가 마운트 될 때 까지, 초기 ref value는 null이며, 참조되는 엘리먼트가 v-if에 의해 마운트 되지 않을 때에도 null로 설정 될 수 있기 때문이다.

Component Template Refs와 타입스크립트 사용하기

때로, 퍼블릭 메소드를 호출하기 위해서 자식컴포넌트로 템플릿 ref를 주석으로 사용해야 할 수도 있다. 예를들면, modal을 열기 위한 메소드를 가진 MyModal 이라는 자식 컴포넌트가 있다:

<!-- MyModal.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const isContentShown = ref(false)
cosnt open = () => (isContentShown.value = true)

defineExpose({
  open
})
</script>

MyModal의 인스턴스 타입을 얻기 위해, typeof를 통해 첫째로 타입을 얻어야 한다. 그리고 타입스크립트의 내장된 instanceType 유틸리티로 인스턴스 타입을 추출한다:

<!-- App.vue -->
<script setup lang="ts">
import MyModal from './MyModal.vue'

const modal = ref<InstanceType<typeof MyModal> | null>(null)

const openModal = () => {
  modal.value?.open()
}
</script>

Vue SFCs 대신에 타입스크립트 파일에서 이 기술을 사용하고 싶으면, Volar's의 Takeover Mode를 사용해야 한다.

댓글