RareJob Tech Blog

レアジョブのエンジニア・デザイナーによる技術ブログです

TypeScript Generics編

APP/UXチームに所属しております、フロントエンドエンジニアの田原です。

皆さん、この夏如何お過ごしでしょうか?
私は夏がとても好きなので本来であれば夏っぽいこと(e.g.海や花火大会や BBQ など)をしたいのですが、 今年はコロナ禍ということもあって外出を極力自粛しているのでこれといって夏らしいことができておりませんでした。

先日、チームのメンバーに「この夏、なにか夏っぽいことしましたか?」と聞いてみたところ 「スイカ食べましたよ ♪」と言われ
なるほど!!どこか外出することやイベントに参加するだけが夏ではないッ...!!
これは盲点だった...っと、とても感銘を受けたと同時にとてもほっこりした気分になりました。

こんな感じで業務以外の話も結構することも多く、チームのメンバーとは仲良しです。
(片思いじゃないって信じてる)
チームだけでなく弊社の開発メンバーは朗らかでとても親切な方々ばかりなので日々とても楽しくお仕事ができております。

さて、少し弊社の雰囲気を感じて頂けたかな?というところで今回の本題に入らせて頂きたいと思います。

弊社でも例の如くTypeScriptを利用して開発を行なっているのですが、今回はその中でも便利な機能であるジェネリクスについて簡単にご紹介できればと思います。

※尚、TypesScriptの基本的な型の説明については一部割愛させて頂いております。

目次

Generics(ジェネリクス)とは?

簡単にいうと型にも引数を設定できるようにして柔軟な取り回しができるようにし、関数やClassの再利用性をあげよう!!というものになります。

これだけでは「お、、、おう、、」
そうなんだなという感想しか出ないと思いますので 以下、簡単な実例で説明します。

引数の内容をそのまま返すだけの簡単な関数があったとします。

const echo = (value) => {
  return value;
};

通常、この関数の引数(value)に型を指定してあげる場合(value:string)(value:number)のように記載すると思います。
引数の型が限定的で明確化している場合についてはその型を指定するで良いのですが複数の型が入る可能性がある時には(value:string|number)というように Union 型で 示したりする場合もあると思います。

const echo = (value: string | number): string | number => {
  return value;
};

Union型のままでも良いのですがこのままではtypeガードやアサーションを設定してあげてvalueの値を関数内の早期に指定してあげないと、 editor上でのインテリセンスがいい感じに効かないという弊害があります。
(つまり、その型の持つJavaScriptの固有のメソッド候補がでない。Union型の場合は共有したものだけがインテリセンスされる。)

const echo = (value: string | number) => {
  // ここではstring型とnumber型に共有したメソッドのみインテリセンスが効く
  // そのため、toUpperCase()などは候補に出ない
  if (typeof value === "string") {
    // ここで初めてstring型だけのインテリセンスが効く
    return value;
  }
};

また、引数の数が限られている場合はこれでも良いのですが以下の様に引数にオブジェクトを渡す場合など、一つ一つ書いていくと冗長になっていくこともあると思います。

const echo = ({ studentId, tutorId, islesson }:{
  studentId: number
  tutorId: number
  isLesson: boolean
}) => {
  return studentId
}

このような時にGenericsを使うとこの様な記述になります。

const echo = <T>(value: T) => {
  return value; // valueの型はTで渡された型になる
};

// 関数呼ぶ時に型を指定してあげる
echo<string>("hello");
echo<number>(2);

Tが型の引数となり関数の引数の型として利用できるようになります。 ※慣例的に大文字のアルファベット(T,S,U)などが使われますがなんでも良いみたいです。

このように柔軟に型を指定できインテリセンスも適切な候補が表示されるようになります。

更にコンパイラが型を推測できる場合には型引数を省略して書けるのでオブジェクトを渡す場合でも冗長にならず柔軟な型指定が可能になります。

const echo = <T>(value: T) => {
  return value; 
};

echo({ studentId: 1, tutorId: 2, isLesson: true });

以下、画像のようにインテリセンス効き、型も適切に判定されていることがわかります。

f:id:ssp0727-lnc:20200828103844p:plain
editor TypeScript

ここまでで簡単に Generics の機能と使い方について理解頂けたかと思います。 以下でもう少し使い方を深掘っていきたいと思います。

■ extends で制約を与える

引数で渡した型に制約を与えたい場合についてはextendsを使います。

const echo = <T extends { lessonSlotNum: number }>(value: T) => {
  // valueに必ずlessonSlotNumが存在する型のメンバとして推測されるためエラーにもならず、インテリセンスも効く。
  console.info(value.lessonSlotNum); // OK
};

echo({ lessonSlotNum: 2 }); // OK
echo({ teacherName: "Mike" }); // Error

このように書くことで関数が呼ばれるときに必ず{ lessonSlotNum: number }の型があるかを確認し、存在しない引数を渡した上で関数を呼ぼうとするとエラーになります。

extendsは以下のようにinterfaceとの組み合わせで指定可能な型を制限させて使うことが多いです。

interface LessonInfoType {
  tutorId: number;
  studentId: number;
}

const echo = <T extends LessonInfoType>(value: T) => {
  return value;
};

echo({ tutorId: 1, studentId: 2 }) // OK
echo({ hobby: "soccer" }); // Error
echo({ tutorId: 1, studentId: 2, hobby: 'soccer' }) // OK

■ keyofを使ってオブジェクトキーの型を動的に型としてプロパディのアクセスに利用する

interface LessonInfoType {
  tutorId: number;
  studentId: number;
}

const echo = <T extends LessonInfoType, U extends keyof T>(
  value: T, // { tutorId: number, studentId: number} の型になる
  key: U // "tutorId" | "studentId" のUnion型になる
) => {
  console.info(value[key])
};

echo({tutorId: 1, studentId: 2}, 'tutorId') // OK
echo({tutorId: 1, studentId: 2}, 'counselingId') // Error

このように型を設定しくことで関数の第一引数に渡したオブジェクトに存在するkey名でのみ、第二引数に渡せるようになります。

■ Vuexで使うとこんな感じで型付けられるよのサンプル

これらを踏まえてVuexでのstoreに簡単に型をつけてみました。
Vuexから提供されている型を拡張する形で型付けを行なっております。
※尚、stateが関数形式なのはNuxt内のVuexであるからです。

何故、これを試してみたかというとVuex4系から入ってくる予定のcreateStoreを使えばVuexの型を CompositionAPIの方法で作成したVueComponentでもTypeSafeに実装ができるようになるみたいなので試してみた次第です。この辺りについてはまたの機会に書きたいと思います。

import { GetterTree, ActionTree, MutationTree, ActionContext } from "vuex";
import { MutationTypes } from "./counterTypes/mutation-types";
import { ActionTypes } from "./counterTypes/action-types";

export const state = () => ({
  counter: 0 as number,
});
export type RootState = ReturnType<typeof state>;

export const getters: GetterTree<RootState, RootState> & Getters = {
  getcounter: (state) => state.counter,
  doubledCounter: (state) => state.counter * 2,
};
export type Getters = {
  getcounter(state: RootState): number;
  doubledCounter(state: RootState): number;
};

export const mutations: MutationTree<RootState> & Mutations = {
  [MutationTypes.SET_COUNTER]: (state, payload: number) => {
    state.counter += payload;
  },
};
export type Mutations<S = RootState> = {
  [MutationTypes.SET_COUNTER](state: S, payload: number): void;
};

export const actions: ActionTree<RootState, RootState> & Actions = {
  [ActionTypes.GET_COUTNER]({ commit }, payload: number) {
    commit(MutationTypes.SET_COUNTER, payload);
  },
};

type AugmentedActionContext = {
  commit<K extends keyof Mutations>(
    key: K,
    payload: Parameters<Mutations[K]>[1]
  ): ReturnType<Mutations[K]>;
} & Omit<ActionContext<RootState, RootState>, "commit">;

export type Actions = {
  [ActionTypes.GET_COUTNER](
    { commit }: AugmentedActionContext,
    payload: number
  ): void;
};

■ まとめ

今更、Genericsの話をするのも情報として鮮度が低いのですが、雰囲気でTypeScriptを使ってしまっていた部分もあったので備忘録も兼ねた内容を書かせて頂きました。 Genericsを使ってでできることについてもまだまだ沢山ありますので、近々第二弾を紹介できればいいなと思います。 まだまだTypeScriptそのものについても全てを理解しきれてはおりませんが面白い機能やテクニカルな方法が沢山あるので適切に使っていけるように更に理解を深めていきたいです。

■ 参考

TypeScript を教えてくれた人