Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
2
Help us understand the problem. What are the problem?

posted at

Organization

TypeScript のユニオン型で様々な状態のオブジェクトを一緒くたに扱うには

この記事について

株式会社Re:Buildアドベントカレンダー Advent Calendar 2020 の 7日目の記事です。

業務委託という立場ではありますが、書いていいよ、と許可をいただいたので、参加させていただきました。

当社の技術スタックとしては Laravel + Nuxt.js (TypeScript) が多く、今年はたくさん TypeScript を書きましたが、本記事では、その中で少し大変だった、ユニオン型を使って複数の異なる型を一緒くたに扱うためにどうしたか、というのをかいつまんで解説します。

具体的には、概念的には同じ型ですが、状態によってプロパティがなかったり追加になったりして、構造的には異なる型のオブジェクトをユニオン型を使って同列に扱えるようにします。

はじめに

ユニオン型とは

型を | でつないで、関数の引数などでいずれの型でも受け取れるようにするものです。

お題

記事(Post)のプロパティを同一のページ上で、編集・追加・削除できる UI になっていて、いずれの状態のオブジェクトも同じ型で扱いたい。具体的には、追加されたオブジェクトには id がない、削除されたオブジェクトには isDeleting という論理値のプロパティがある、という違いがあります。これらをリストにして保存用 API に送ってまとめて更新する、という流れです。

基本の Post 型

declare module Response {
  interface Post {
    id: number
    title: string
    author: string
  } // 他にもたくさんあるけど省略
}

API をモックした関数

const fetchPosts = (): Promise<Response.Post[]> => {
  return new Promise((resolve) => {
    const posts: Response.Post[] = [
      { id: 1, title: '記事1', author: 'nunulk' },
      { id: 2, title: '記事2', author: 'nunulk' },
      { id: 3, title: '記事3', author: 'nunulk' },
    ]
    resolve(posts)
  })
}

リクエストボディのイメージ

[
  { "id": 1, "title": "記事1", "author": "nunulk" },
  { "id": 2, "title": "削除された記事", "author": "nunulk", "isDeleting": true },
  { "title": "新規作成された記事", "author": "nunulk" },
]

ユニオン型で様々な状態のオブジェクトを一緒くたに扱う

前述のとおり、同一画面上で、編集・追加・削除を行い、それらすべてを同一の型として扱えるようにします。

// 既存のデータ
type EditingPost = Response.Post
// 追加された(id が存在しないか、存在しても null である)
type CreatingPost = Omit<EditingPost, 'id'> & { id?: null }
// 削除された(isDeleting というプロパティが true である)
type DeletingPost = EditingPost & {
  isDeleting: true
}
type Post = EditingPost | CreatingPost | DeletingPost

コンポーネント定義と外部インタフェースはこんなかんじです。

export default Vue.extend({
  data: () => ({
    posts: [] as Post[],
  }),
  async created () {
    // 既存のデータを取得する
    this.posts = await fetchPosts()
  },
  methods: {
    onSubmit () {
      // 保存用の API を呼び出す
      savePosts(this.posts)
    },
  },
})

各操作と配列要素の置き換え

新規作成

新規作成の場合は、単純にオブジェクトを Array.push() すればいいです(プロパティ名の間違いなどあればコンパイルエラーになります)。

/*
createForm: {
  title: '' as string,
  author: '' as string,
},
*/
this.posts.push(this.createForm)

削除

削除の場合はちょっと複雑で、新規作成されたものがサーバに保存される前に削除された場合はそのまま this.posts から消してしまいます。Vue.set に型情報を渡してやることで第3引数の型アサーションができるようになります。

// index は削除される要素の添字
const post = this.posts[index]
if (post.id) {
  this.$set<DeletingPost>(this.posts, index, { ...post, isDeleting: true })
} else {
  this.posts.splice(index, 1)
}

編集

編集の場合は、既存の posts を直接変えても、別で data に定義してもいいですが、型は変わらないので省略します。

各状態を型ガードで識別する

配列の各要素がどの状態(型)であるかを識別したいシチュエーションがあるかもしれません。その場合は、型ガードを使って識別します。

const isCreating = (post: Post): post is CreatingPost => typeof post.id !== 'number'
const isDeleting = (post: Post): post is DeletingPost => 'isDeleting' in post && post.isDeleting === true
const isEditing = (post: Post): post is EditingPost => !isCreating(post) && !isDeleting(post)

this.posts.forEach((post: Post) => {
  if (isCreating(post)) {
    console.log('creating', post)
  } else if (isDeleting(post)) {
    console.log('deleting', post)
  } else if (isEditing(post)) {
    console.log('editing', post)
  }
})

おわりに

いかがでしたでしょうか。

  • ベースとなる型から Omit や 交差型を使って異なる状態の型を導出する
  • それらをユニオン型で統合する
  • 型ガードを使って型を判別する

といった方法で、様々な状態のオブジェクトを一緒くたに扱う方法をご紹介しました。

コードに間違いなどあればコメント欄にてご指摘いただけると助かります。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
2
Help us understand the problem. What are the problem?