Vue3 Composition APIで学ぶジェネリクス

6 min read読了の目安(約5700字

この記事は

VueのComposition APIのref()reactive()の実装を読んだら、TypeScriptのジェネリクスの使い方が腹落ちしたのでまとめてみた記事になります。

VueのComposition APIではリアクティブな値を得るためにはref()reactive()といった関数を使う必要があるかと思います。この2つの基本的な使い分けとしては、ざっと調べた感じでは基本的に以下のように使い分けるようです。

  • ref() => プリミティブ型に使う
  • reactive() => オブジェクトに使う

ただ使う分にはこの使い分けで問題なさそうですが、せっかくなので実装にまで踏み込んでみることにしました。
いざ読んでみて
・ジェネリクスってこういうふうに使いたいのねというのが分かった
・なぜreactive()はオブジェクトに使うのか分かった

という学びがありました。せっかくなので実際のソースコードを引用しつつ、そこでのジェネリクスの使い方をみていきたいと思います。

ソースはこちら。

<T>...?

export function reactive<T extends object>(target: T): UnwrapNestedRefs<T>
export function reactive(target: object) {
  // if trying to observe a readonly proxy, return the readonly version.
  if (target && (target as Target)[ReactiveFlags.IS_READONLY]) {
    return target
  }
  return createReactiveObject(
    target,
    false,
    mutableHandlers,
    mutableCollectionHandlers
  )
}

ここがreactive()関数の型定義で、

export function reactive<T extends object>(target: T): UnwrapNestedRefs<T>

その下の部分がその実装部分でしょう。
ここでは、引数で渡したtargetがreadonlyであればtargetを返し、そうでなければcreateReactiveObject()を呼び出しているようです。

export function reactive(target: object) {
  // if trying to observe a readonly proxy, return the readonly version.
  if (target && (target as Target)[ReactiveFlags.IS_READONLY]) {
    return target
  }
  return createReactiveObject(
    target,
    false,
    mutableHandlers,
    mutableCollectionHandlers
  )
}

1つずつ確認していきます。まず<T extends object>の部分ですが、このような<T>のような記述をジェネリクスと呼び、実行されるまで型が確定しないことを表現しています。

どういうことかというと、reactive()を使うときのことを考えてみるとわかりやすいのですが、reactive()にはstringやDateが渡されることもあれば、Dateが渡されたり自分たちで作成した独自の型の値が渡される場合もあります。

// 文字列型が渡されるかもしれないし、(※一般的にはref()を使うとは思う)
const hoge = reactive('hoge');
// Date型が渡されるかもしれないし、
const date = reactive(new Date());
// 独自の型を持った値が渡されるかもしれない
const user:User = reactive({
  userID: fuga,
  name: 'captain-blue210'
});

要するに、どんな型の値が渡されるかは実際に使われるまで分からないわけです。なのでライブラリとしては、reactive()関数はどんな型でも扱えるようにしたい。そこで型一般を表すためにジェネリクス、<T>を使う必要がでてきます。

function sampleGenerics<T>(arg: T){
  // 処理
}

このように書くことで、下記サンプルのようにsampleGenerics()メソッドをを使うときに渡す値で型を決められるようにできます。

// 文字列型を渡せば、argの型がstringになる
sampleGenerics('test');

// Date型を渡せば、argの型がDateになる
sampleGenerics(new Date());

anyでは駄目なのか?

どんな型でも受け入れられるようにしたいならanyにするという手段もあります。しかし、anyにした場合たしかにどんな型でも受け入れられるようになりますが、その代償として、渡した値の型情報を利用することができません。たとえば以下のような関数を考えてみます。

function test(arg: any){
  console.log(arg.length);
}

引数のargany型のため、arg.lengthでコンパイルエラーになりません。ところが実際には

function test(arg: any){
  console.log(arg.length);
}

test('test'); // 4
test(100); // undefined

test(100)のようにlengthプロパティがない値を渡すとundefinedになってしまいます。TypeScriptでせっかく型情報を扱えるわけですから、これは事前に検知したいです。では、ジェネリクスを使うとどうなるでしょうか

function sampleGenerics<T>(arg: T){
  console.log(arg.length)
}

ジェネリクスを使った場合、コンパイルエラーを出してくれるので、上記のように予期せぬ値になってしまうことを事前に防ぐことができます。

Property 'length' does not exist on type 'T'.

ちなみに、引数の型をT型の配列にするとコンパイルが通るようになります。配列であればlengthプロパティは存在するからですね。

function sampleGenerics<T>(arg: T[]){
  console.log(arg.length)
}

さて、改めてreactive()のソースを確認してみると、reactive()にはいろんな型が渡される可能性があるからジェネリクスを使っているんだろうなーというのが分かるのではないでしょうか。

export function reactive<T extends object>(target: T): UnwrapNestedRefs<T>

<T extends object>...?

<T>がジェネリクスで、reactive()に柔軟に型を渡せるようにしているのは分かりました。しかしソースでは<T>ではなく<T extends object>となっています。これは何をしたいのでしょうか?

Tの型を制限したい

sampleGenerics<T>(arg: T)だと、型としてはどんな型でも入れることができます。型情報を維持しつつ、関数に柔軟性をもたせることができるようになりました。しかし実際にはここまで自由に型を受け入れるのではなく、Tの型を制限したい場合がでてきます。

そこで、ジェネリクスの型を制限することもできます。型の制限を実現しているのが<T extends hogehoge>extendsです。
まず、簡単な例を挙げてみます。

type Item = {
  name: string;
  price: number;
}

// 型をItem型のみに制限する
function sampleGenerics<T extends Item>(arg: T) {
  console.log(arg.name);
}

const item:Item = {
  name : 'test',
  price: 1000
}

sampleGenerics(item); // test

Item型を満たしていない値を渡すとコンパイルエラーになります。

type Item = {
  name: string;
  price: number;
}

// 型をItem型のみに制限する
function sampleGenerics<T extends Item>(arg: T) {
  console.log(arg.name);
}

const item = {
  name : 'test',
}

sampleGenerics(item);

Argument of type '{ name: string; }' is not assignable to parameter of type 'Item'.
Property 'price' is missing in type '{ name: string; }' but required in type 'Item'.

reactive()の型定義のもう一度確認してみましょう。

export function reactive<T extends object>(target: T): UnwrapNestedRefs<T>

<T extends object>で型をobject型のみに制限していることが分かります。
それでは、objectは何を表しているのでしょうか?

object型とは?

これは単純にプリミティブ型以外のオブジェクト型を表します。要するにここではオブジェクト型のみを引数として受け入れるように制限をかけている、
ということになります。ここまで来ると、「reactive()はオブジェクトに使う」というのが実装上もそうなっているんだということが分かります。

まとめ

  • Vue3のComposition APIにあるreactive()関数はジェネリクスを使ってオブジェクトのみを引数に取れるようにしている

関数に柔軟性をもたせつつ、型の利点を活かす実装がジェネリクスを使うとできることが学べました。特にライブラリを書こうとなったらこれを駆使することになりそうですし、普段の業務でもうまく使えばコードを簡潔に記述できそうなので取り入れていきたいですね。

参考