Vue.js+TypeScriptのプロジェクトで型推論をするTIPS


はじめに

Vue #2 Advent Calendar 2019の8日目です。
本日は@watsuyo_2が「Vue.js+TypeScriptのプロジェクトで型推論をするTIPS」を記事にします!

フロントエンドエンジニア1年目を振り返りながら、これからVue.jsを学ぶ人や業務経験は浅いがVue.jsにTypeScriptを導入していきたい方が分かりやすいようにまとめています。

普段、 Nuxt.js(Vue.js) と Firestore を使用した開発をしているので例としてでてきますがご了承ください。

↓は1年前に転職するまでの流れを書いた記事にあります。
新年からWebエンジニアになる僕に2018年、起こったこと

前提条件

Vue.extendを使う

Vue.jsでTSを書く場合、Vue.extend、vue-class-component、vue-property-decoratorといった選択肢がVue.jsにはありますが、今回はVue.extendを採用した事例になります。
好みの問題でもありますが、素に一番近い点が自分は気に入っています。

any型という逃げ道

TypeScriptを使う実際の業務では

「一旦はanyにしておこう」

「型も大事だけど機能実装を優先しよう」

といったケースは多いかと思います。

実際には開発初期段階でany型の宣言を書いてしまったことによって、予期せぬ値が変数や関数の引数に入ることに気づかなかったり、後でやれば大丈夫と思っていても誰かがやるだろうと放置したり、any型を定義していたことすら忘れてしまう可能性があります。

確かに、開発速度を上げて機能実装していくことによるメリットも開発初期段階では無くはないですが、型定義をおざなりにした代償としてリファクタにかける工数や、開発メンバーが増えてきた時にコードの治安を一定値まで保つことも難しくなります。

やらないほうがいいことと型推論方法

1. map〇〇系のヘルパー関数の利用

  • mapState
  • mapMutations
  • mapGetters
  • mapActions

のようにcomponentからstoreにアクセスするために使用する関数です。
ただヘルパー関数を使用すると全てany型として処理されます。
前提として、直接StateやMutationにアクセスすることは、秩序を守る意味でもVuexライフサイクル内での型定義を固める意味でも避けて開発をしています。

そのため、TypeScriptを使用する場合は、computed内でstoreからのstateをgetters経由で取得し、return値に型を定義します。

例えば、stateにある全てのtods配列を取得する場合に以下のようなcomputedを書きます。

// ここはvuex側では無く、componentで使用する型を書いたファイルをimportする
import { TodoData } from '~/types/firestore'

~~
computed: {
  todos: TodoData[] {
    return this.$store.getters['todo/all']
  }
}

こうすることで、todosthisで参照する場合に型推論が効きます。

2. this.$store.stateで直接stateを参照する

Vuexのライフサイクルを無視した、stateの参照は秩序を乱すだけでなく、型推論も効かないためオススメできません。

そのため、1で言及したとおりthis.$store.gettersをcomputedで利用し、return値に型を定義
することが望ましいです。

3. (this as any).hogeによるthisのanyマッピング

なぜ、泣く泣く(this as any).hogeのようにthisをanyでマッピングするのかというとthisの参照先にundefindの可能性があるためでです。

import { TodoData } from '~/types/firestore'

~~
computed: {
  todos: TodoData[] {
    return this.$store.getters['todo/all']
  },
  newlestTodo: {
    // todosにundefindの可能性があればthisはtypeエラーとなる
    return Math.max(...this.todos)
  }
}

computedで値を定義するシチュエーションでは迷わず、nullの許可をしましょう。
また、this.todosをが存在していたら処理を行うようにすると型エラーを免れ、型推論もなされます。

この策は、TypeScript 3.7のOptional chaining
を導入していない場合に有効です。

import { TodoData } from '~/types/firestore'

computed: {
  // todosにnullの可能性を定義しておく
  todos: TodoData[] | null {
    return this.$store.getters['todo/all']
  },
  newlestTodo: string | null {
    // this.todosnullの場合はnullを、存在していれば最新のtodoを取得する
    return this.todos ? Math.max(...this.todos) : null
  }
}

やったほうが良いこととやるべきこと

store編

storeの型定義はtypesディレクトリを作成し定義します。
以下の例はTodoというstateで扱うデータ型を定義し、
- State
- Actions
- Getters
- Mutations
といったVuexのライフサイクルで使用する型を定義します。

ここで定義する型はあくまでもVuex内で使われる値の型推論が保証されるものです。
そのため、computedやdataでは別途


import firebase from 'firebase'

export interface Todo {
  id: string
  userId: string
  todo: string
  imageUrl: string
  status: string
  createdAt: firebase.firestore.Timestamp
  updatedAt: firebase.firestore.Timestamp
}

export interface State {
  all: Todo[]
}

export interface Getters {
  all: Todo[]
}

export interface RootGetters {
  'todo/all': Getters['all']
}

export interface Mutations {
  setTodos: { todos: Todo[] }
}

export interface RootMutations {
  'todo/setTodos': Mutations['setTodos']
}

export interface Actions {
  fetchByUserId: { userId: string }
}

export interface RootActions {
  'todo/fetchByUserId': Actions['fetchByUserId']
}

data編

dataを定義する際、与えられた値によって型が定義されますが、templateからの入力やmethodによって値が書き換えられることも考慮して、interfaceを定義することができます。
例えばユーザー情報を入力する画面かつモーダルを実装する場合

vue.js
interface LocalData {
  name: string
  address: {
    zipcode: number | null // numberの初期値はnullにしておく
    region: string
    locality: stirng,
    streetAddress: string,
    extendedAddress: string  
  }
  showModal: boolean
}

export default Vue.extend({
  data: (): LocalData => ({
    name: ''
    address: {
      zipcode: null
      region: ''
      locality: '',
      streetAddress: '',
      extendedAddress: ''  
    },
    showModal: false
  })
})

LocalDataような名前でそのコンポーネント
でのみ使用するinterfaceを定義出来ます。

もちろん、外部ファイルにて定義しているのであればそれをimportして定義することも出来ます。

computed編

上記のやったほうが良いこととやるべきことであげた対応法をご覧ください。

props編

親コンポーネントか子コンポーネントへ値を渡す際に使用するpropsでも型定義ができます。
親コンポーネントで定義した型と同じ型をここで定義することで型安全が保たれます。

vue.js
props: {
  todo: {
    type: String,
    default: ''
    required: true
  }
}

mixin編

mixinでVuexからtodosを取得し、各コンポーネントでtodosを扱いたい時は

const mixin = Vue.extend({
  todos: TodoData[] {
    return this.$store.getters['todo/all']
  }
})

export default mixin
vue.js
import todoMixin from '~/assets/todo_mixin'

export default Vue.extend({
  mixins: [todoMixin]
})

のようにimportをしてあげるかと思います。
ただこのままだとtodoをtemplate内で使用はできますが、computedやmethodsではthisを使った参照で型エラーが発生します。

ここでも同様にtypesディレクトリ内に型定義ファイルを用意してあげます。

vue.d.ts
import Vue from 'vue'
import { TodoData } from '~/types/firestore'

declare module 'vue/types/vue' {
  interface Vue {
    todos: TodoData[]
  }
}

これで型推論が効きます◎

終わりに

業務で扱うことでTypeScriptのありがたみや扱いづらさも感じていましたが、プロジェクトやプロダクトのグロースに向けて、品質の高いコードと治安を守るためにも以上のTIPSを使いながらコーディングをすると良いかもしれません。

途中にも登場した、Optional chainingやVue.js3.0でのTypeScript対応によってはよりよい体験が提供され、今回紹介したTIPSを必要としなくなるかもしれませんが2019年12月現在、TypeScript3.7をプロジェクトに導入していない場合は役立つと思います!

メンションつきツイートをしていただけるとたいへん喜びます!

thanks-mentionsというQiitaの記事を作者に対してメンションを飛ばしながらツイートが出来るPWAを作りました🚀
ぜひ、メンションつきツイートをしていただけるとたいへんとてもとても喜びます!

Vue #2 Advent Calendar 2019

明日、Vue #2 Advent Calendar 2019の9日目は、イイダリョウ @idr_zz
さんです!

※2020年1月更新

MENTAでコードレビューやプログラミング勉強相談を行っています!

お気軽にメッセージを下さい!
プランはこちらになります!