Edited at

Vue.js in TypeScript で型定義をどこに書けばいいか


この記事について

表題の通りですが、いくつかバリエーションあるよなぁと思ったので、自分の思考を整理するために文書化しました。


パターン


  1. API から取得するバックエンドと共通で使用するデータモデルの型

  2. フロントエンドでのみ使用する型(関連する関数を持つドメインモデル)

  3. コンポーネント内のみで使用する型

  4. 親子コンポーネント間で共有する型


TODO 管理を例にとって考えます。


1. API から取得するバックエンドと共通で使用するデータモデルの型

types/task.d.ts に書く。

コンポーネントやストア(Vuex)から API を呼び出した際に、戻ってくるオブジェクト用の型です。

JSON を object にしただけなので、メソッドはありません。

any で扱うよりは interface として扱ったほうがいい場合に使います。

export interface TaskData {

id?: number;
name?: string;
state?: string; // enum でも
priority?: string; // enum でも
}

使い方はこんなかんじ:

const response = await axios.get(url);

const task: TaskData = response.data;


2. フロントエンドでのみ使用する型(関連する関数を持つドメインモデル)

modules/task.ts に書く。

API から返ってくるデータもドメインモデルの型ですが、こちらは単なるデータモデルなので、オブジェクト指向的に扱おうとすると少々めんどくさいことになります(なので、個人的には関数型っぽくやるほうがフィットするとは思いますが、オブジェクト指向的にやろうとするとクラスを使いたくなります)。

modules は classes でも domain でも models でもなんでもいいですが、ドメインモデルに関するモジュールをここに入れます。

import { TaskData } from '@/types/task';

export default class Task {
private data: TaskData;
constructor(data: TaskData) {
this.data = data;
}
get data(): TaskData {
return this.data;
}
public close(): void {
this.data.status = 'closed';
}
}

以下のような関数型っぽい構成にしてもいいかもしれません。

import { TaskData } from '@/types/task';

export const closeTask = (task: TaskData): void => task.status = 'closed';

ちょっと例が単一のプロパティを更新するだけのやつなのでアレなんですが、もっと複雑なやつだと関数化する意味があるので、そういうのを想像していただければ。

使い方はこんなかんじ:

import Task from '@/modules/task';

// or
import { closeTask } from '@/modules/task';

export default Vue.extend({
props: {
task: {
type: Object as Task,
required: true,
},
},
// 略
methods: {
// ボタンなどの「タスクを完了する」アクション用のイベントハンドラ
onCloseTask(): void {
this.task.close();
// or
closeTask(this.task);
},
},
}


3. コンポーネント内のみで使用する型

コンポーネントの中に書く。

特定の Vue コンポーネント内でのみ使用する型は、コンポーネント内で宣言します。

例えば、タスク一覧ページで絞り込みをするようなコンポーネントがあったとき、「すべて」「完了」「未完了」の3つで絞り込めるとしたときに、型を使って、

enum FilteringTaskStatus {

All = 'all',
Done = 'done',
Undone = 'undone',
}

const filterTaskStatus = (task: Task, value: FilteringTaskStatus): boolean => {
if (value === FilteringTaskStatus.All) {
return true;
}
if (value === FilteringTaskStatus.Done) {
return task.done();
}
if (value === FilteringTaskStatus.Undone) {
return !task.done();
}
return false;
};

type FilterValue = FilteringTaskStatus; // 型が増えたら Union にする

interface TaskFilter {
value: FilterValue;
filter: (task: Task, value: FilterValue) => boolean;
}

interface TaskFilters {
//なぜか [] を入れると行ごと消えてしまうのでコメントにしましたが実際にはコメントではありません
//[key: string]: TaskFilter;
}

使い方

export default Vue.extend({

data() {
return {
filters: TaskFilters = {
'status': {
value: FilteringTaskStatus.All,
filter: filterTaskStatus,
},
},
}
},
computed: {
filteredTasks(): Task[] {
return this.tasks.filter((task: Task): boolean => {
for (let key in this.filters) {
const f = this.filters[key].filter;
const v = this.filters[key].value;
if (!f(task, v)) {
return false;
}
}
return true;
});
},
},
}


4. 親子コンポーネント間で共有する型

子コンポーネントの中に書く。

props down / events up の方式で親子間でデータのやり取りをする場合、emit する側と handle する側で型を合わせておいたほうがいい局面があります(オブジェクトを返すようなとき)。

またしてもいい例が思い浮かばなかったんですが、タスク作成モーダルを子コンポーネントに持っているタスク一覧ページコンポーネントがあったとき、「続けて作成」チェックボックスをオンにするとモーダルを閉じずに次のタスクを作成できる、みたいな UI だったとき、作成したタスクオブジェクトと boolean の値を一緒に送ることになります。

子コンポーネント

export interface TaskCreateResult {

task: Task;
isContinued: boolean;
}

export default Vue.extend({
// (略)
emitCreate(value: TaskCreateResult): void {
this.$emit('create', value);
}
});

親コンポーネント

import TaskCreateModal, { TaskCreatedResult } from '@/components/TaskCreateModal';

export default Vue.extend({
methods: {
onOkClick(result: TaskCreatedResult) {
if (!result.isContinued) {
this.visibleCreateModal = false;
}
},
},
});


おわりに

他にも、こういうケースではここに型定義を置くといいよ、みたいなのありましたら、コメント欄にて教えていただけると助かります :bow: