概要
TypeScriptを学習していき、初心者からもう一歩進んで中級者になるために、私が参考になったものを紹介します。
それを「良いコード」と「悪いコード」という形式で書いていきます。「こういう書き方に直せるんだ」「こう書いたほうがいいんだ」というのが直感的にわかりやすいと思います。しばらく経ってからまた見返すものとしても良いです。
元は以下で公開されている「Clean Code」の記事です。
https://msakamaki.github.io/clean-code-typescript/
私の記事は、あまりにも基本的なことは除外したり、逆に難しいところは解説を加えたものです。
時間のある人は元の記事の全文に目を通しても良いと思います。
他に参考にしたもの
どのサイトも素晴らしいので、TypeScript を学習するならオススメです。
フォーマット
タイプスクリプトエイリアスを使用する
tsconfig.json の compilerOptions セクションで、path と baseUrl プロパティを定義するとより綺麗なimportを書くことができる。
これにより、import時に長い相対パスの使用を避けることができる。
Bad:
import { UserService } from '../../../services/UserService';
Good:
import { UserService } from '@services/UserService';
tsconfig.json
...
"compilerOptions": {
...
"baseUrl": "src",
"paths": {
"@services": ["services/*"]
}
...
}
...
関数
引数
- 関数における引数は2つ以下が理想。それ以上は関数がやりすぎている可能性がある。
- どうしても必要ならオブジェクトリテラルの利用を検討する。例を以下に示す。
Bad:
function createMenu(title: string, body: string, buttonText: string, cancellable: boolean) {
// ...
}
createMenu('Foo', 'Bar', 'Baz', true);
Good:
function createMenu(options: { title: string, body: string, buttonText: string, cancellable: boolean }) {
// ...
}
createMenu({
title: 'Foo',
body: 'Bar',
buttonText: 'Baz',
cancellable: true
});
さらにGood:
type MenuOptions = { title: string, body: string, buttonText: string, cancellable: boolean };
function createMenu(options: MenuOptions) {
// ...
}
createMenu({
title: 'Foo',
body: 'Bar',
buttonText: 'Baz',
cancellable: true
});
デフォルト値の指定
Object.assign や 分割代入を使ってデフォルトオブジェクトを設定するとよい。
Bad:
type MenuConfig = { title?: string, body?: string, buttonText?: string, cancellable?: boolean };
function createMenu(config: MenuConfig) {
config.title = config.title || 'Foo';
config.body = config.body || 'Bar';
config.buttonText = config.buttonText || 'Baz';
config.cancellable = config.cancellable !== undefined ? config.cancellable : true;
// ...
}
createMenu({ body: 'Bar' });
Good:
type MenuConfig = { title?: string, body?: string, buttonText?: string, cancellable?: boolean };
function createMenu(config: MenuConfig) {
const menuConfig = Object.assign({
title: 'Foo',
body: 'Bar',
buttonText: 'Baz',
cancellable: true
}, config);
// ...
}
createMenu({ body: 'Bar' });
もしくはこちらもGood:
type MenuConfig = { title?: string, body?: string, buttonText?: string, cancellable?: boolean };
function createMenu({ title = 'Foo', body = 'Bar', buttonText = 'Baz', cancellable = true }: MenuConfig) {
// ...
}
createMenu({ body: 'Bar' });
命令型プログラミングよりも関数型プログラミングを好む
Bad:
const contributions = [
{
name: 'Uncle Bobby',
linesOfCode: 500
}, {
name: 'Suzie Q',
linesOfCode: 1500
}, {
name: 'Jimmy Gosling',
linesOfCode: 150
}, {
name: 'Gracie Hopper',
linesOfCode: 1000
}
];
let totalOutput = 0;
// 以下のように for 文でグルグル回しがちだが...
for (let i = 0; i < contributions.length; i++) {
totalOutput += contributions[i].linesOfCode;
}
Good:
const contributions = [
{
name: 'Uncle Bobby',
linesOfCode: 500
}, {
name: 'Suzie Q',
linesOfCode: 1500
}, {
name: 'Jimmy Gosling',
linesOfCode: 150
}, {
name: 'Gracie Hopper',
linesOfCode: 1000
}
];
// reduce で書ける
const totalOutput = contributions
.reduce((totalLines, output) => totalLines + output.linesOfCode, 0);
この例ではやや難しいと言われる reduce が使われているが、map, filter, find などの基本的な関数が使いこなせるとグッと TypeScript が上達する。
reduce やその他関数の解説は以下の記事がわかりやすかった。
https://zenn.dev/nekoniki/articles/07c09eb6811c85a753de
イテレーターとジェネレーター
少し難しいが、意外と使いどころが多いのがこのイテレーターとジェネレーターの概念。ストリーミングのようなコレクションを扱うときは、これが使えないか考えてみてほしい。
function*
と yield(イールド)
という構文を使い、ジェネレーター関数を作る。このジェネレーター関数を呼び出すと、ジェネレーターオブジェクトが返るのだが、ジェネレーターオブジェクトはイテレーターインターフェースを実装している。つまり、 next
return
throw
関数が使えるのである。
例を見てみよう。
function* idMaker(){
let index = 0;
while(index < 3)
yield index++;
}
// ジェネレーター関数を呼び出し、ジェネレーターオブジェクトを得る
let gen = idMaker();
// next 関数を使って「0」「1」「2」という結果を順番に得ることができる
console.log(gen.next()); // { value: 0, done: false }
console.log(gen.next()); // { value: 1, done: false }
console.log(gen.next()); // { value: 2, done: false }
// イテレーターが終了した場合には { done: true } という結果が得られる
console.log(gen.next()); // { done: true }
function* idMaker
関数には return
がない変わりに yield
が使われているのがポイント。 yield
を使うと「遅延評価される」のだが、これは誤解を恐れず言えば「その都度return」されるようなイメージである。
また、イテレーターなので、for-of 構文を使った組み込みの反復処理もサポートしている。
これを例にした悪いコード、良いコードを見てみよう。
Bad:
// フィボナッチ数を出力する関数
function fibonacci(n: number): number[] {
if (n === 1) return [0];
if (n === 2) return [0, 1];
// 普通にやると return する配列の全数が必要だが...
const items: number[] = [0, 1];
while (items.length < n) {
items.push(items[items.length - 2] + items[items.length - 1]);
}
return items;
}
function print(n: number) {
fibonacci(n).forEach(fib => console.log(fib));
}
// フィボナッチ数の最初の10個をprintする
print(10);
Good:
// フィボナッチ数の無限のストリームを生成する関数
function* fibonacci(): IterableIterator<number> {
let [a, b] = [0, 1];
while (true) {
// ジェネレータを使うことで、全数の配列を保持しなくてよくなる
yield a;
[a, b] = [b, a + b];
}
}
function print(n: number) {
let i = 0;
// for-of でループ処理できる
for (const fib of fibonacci()) {
if (i++ === n) break;
console.log(fib);
}
}
// フィボナッチ数の最初の10個をprintする
print(10);
これだけの例だとあまりピンとこないかもしれない。
詳しくは以下の解説記事を読んでほしい。
https://typescript-jp.gitbook.io/deep-dive/future-javascript/generators
オブジェクトとデータ構造
getter と setter を使う
TypeScriptは getter / setter 構文をサポートしている。
getter と setter を使って振る舞いをカプセル化し、オブジェクトにアクセスするほうが優れている可能性がある。 理由としては以下。
- もしオブジェクトのプロパティを取得する以上のことをしてる場合、コード内のすべてのアクセサを調べて変更する必要がない。
- set を使うとバリデーションが追加できる。
- 内部をカプセル化できる。
- 値を取得や設定する時にログやエラー処理を追加するのが容易になる。
- オブジェクトのプロパティを遅延ロードすることができるようになる。例えばサーバから値を取得する時など。
例を見てみよう。
Bad:
type BankAccount = {
balance: number;
// ...
}
const value = 100;
const account: BankAccount = {
balance: 0,
// ...
};
if (value < 0) {
throw new Error('Cannot set negative balance.');
}
// アカウントオブジェクトの balance に値を入れるコード
account.balance = value;
Good:
// これで BankAccount はカプセル化された
class BankAccount {
private accountBalance: number = 0;
get balance(): number {
return this.accountBalance;
}
set balance(value: number) {
// setter の中でバリデーションをすることができる
if (value < 0) {
throw new Error('Cannot set negative balance.');
}
this.accountBalance = value;
}
// ...
}
const account = new BankAccount();
account.balance = 100;
ある日仕様が変更されて、追加のバリデーションが必要になった場合にも setter
の実装だけを変更すればよく、すべての依存したコードを変更する必要はなくなった。
タイプ vs インターフェース
TypeScript には type
と interface
があり、どちらも近いことができるためどう使うかを迷うことがある。
例えば以下を見てほしい。
interface Animal {
name: string;
bark(): string;
}
type Animal = {
name: string;
bark(): string;
};
どちらも「動物」を表現しているものだが、どちらが適しているのか。
まず、union
や intersection
が必要な場合は type
を使用する。 extends
や implements
がほしいときには interface
を使う。と覚えておくと良いだろう。
これを使った悪いコード、良いコードを見てみよう。
Bad:
interface EmailConfig {
// ...
}
interface DbConfig {
// ...
}
interface Config {
// ...
}
//...
type Shape = {
// ...
}
Good:
type EmailConfig = {
// ...
}
type DbConfig = {
// ...
}
type Config = EmailConfig | DbConfig;
// ...
interface Shape {
// ...
}
class Circle implements Shape {
// ...
}
class Square implements Shape {
// ...
}
インヘリタンス(継承)よりコンポジション(合成集約)を好む
可能ならば継承よりも合成集約を優先するべき。もし継承を使いたくなったら、まず合成集約でやれないか考えてみるとよい。
では逆にどういうときに継承を使うべきか?
- 継承が「has-a」ではなくて「is-a」を表している場合(例:人間は動物である)
- 基底クラスからコードを再利用できる(例:人は動物のように動くことができる)
- 基底クラスを変更することで、派生クラスを全体的に変更したい(例:全ての動物の移動中の消費カロリーを変更する、など)
以下は継承の誤った使い方。
Bad:
class Employee {
constructor(
private readonly name: string,
private readonly email: string) {
}
// ...
}
// よくない。なぜなら、従業員(Employee)は税情報を持っている。
// しかし、従業員税情報(EmployeeTaxData)は従業員ではない。
class EmployeeTaxData extends Employee {
constructor(
name: string,
email: string,
private readonly ssn: string,
private readonly salary: number) {
super(name, email);
}
// ...
}
Good:
class Employee {
private taxData: EmployeeTaxData;
constructor(
private readonly name: string,
private readonly email: string) {
}
// 従業員は税情報を持つ、「has-a」の関係
setTaxData(ssn: string, salary: number): Employee {
this.taxData = new EmployeeTaxData(ssn, salary);
return this;
}
// ...
}
class EmployeeTaxData {
constructor(
public readonly ssn: string,
public readonly salary: number) {
}
// ...
}
メソッドチェーンを利用すること
Bad:
class QueryBuilder {
private collection: string;
private pageNumber: number = 1;
private itemsPerPage: number = 100;
private orderByFields: string[] = [];
from(collection: string): void {
this.collection = collection;
}
page(number: number, itemsPerPage: number = 100): void {
this.pageNumber = number;
this.itemsPerPage = itemsPerPage;
}
orderBy(...fields: string[]): void {
this.orderByFields = fields;
}
build(): Query {
// ...
}
}
// ...
const queryBuilder = new QueryBuilder();
queryBuilder.from('users');
queryBuilder.page(1, 100);
queryBuilder.orderBy('firstName', 'lastName');
const query = queryBuilder.build();
Good:
class QueryBuilder {
private collection: string;
private pageNumber: number = 1;
private itemsPerPage: number = 100;
private orderByFields: string[] = [];
from(collection: string): this {
this.collection = collection;
return this;
}
page(number: number, itemsPerPage: number = 100): this {
this.pageNumber = number;
this.itemsPerPage = itemsPerPage;
return this;
}
orderBy(...fields: string[]): this {
this.orderByFields = fields;
return this;
}
build(): Query {
// ...
}
}
// ...
const query = new QueryBuilder()
.from('users')
.page(1, 100)
.orderBy('firstName', 'lastName')
.build();
エラー処理
Throw や Reject の時に Error を使う
TypeScript は任意のオブジェクトを throw できる。 Promiseの場合は reject することができる。
Error 型と throw 構文を使うようにする。エラーはより上位のコードで catch され適切に処理したい。その時に文字列でメッセージを出すのはとても混乱を招き、デバッグを厄介にする。同じ理由で Error 型の reject を行うべき。
Bad:
function calculateTotal(items: Item[]): number {
throw 'Not implemented.';
}
function get(): Promise<Item[]> {
return Promise.reject('Not implemented.');
}
Good:
// Good
function calculateTotal(items: Item[]): number {
throw new Error('Not implemented.');
}
// Promise を使っている場合の Good 例
function get(): Promise<Item[]> {
return Promise.reject(new Error('Not implemented.'));
}
// もしくはこれでも良い
async function get(): Promise<Item[]> {
throw new Error('Not implemented.');
}
Error 型を使う利点は、try
catch
finally
構文がサポートされていること、暗黙的に stack プロパティを持つためデバッグにしやすいからだ。 throw
構文を使わずに常にカスタムエラーオブジェクトを返すという方法もある。 TypeScriptはそれを更に簡単する。 次の例を参考に。
type Result<R> = { isError: false, value: R };
type Failure<E> = { isError: true, error: E };
type Failable<R, E> = Result<R> | Failure<E>;
function calculateTotal(items: Item[]): Failable<number, 'empty'> {
if (items.length === 0) {
return { isError: true, error: 'empty' };
}
// ...
return { isError: false, value: 42 };
}
並行性
コールバックではなく Promise, async / await を使う
コールバックが無数にも繋がり、コールバック地獄と呼ばれるコードが以下である。
Bad:
import { get } from 'request';
import { writeFile } from 'fs';
function downloadPage(url: string, saveTo: string, callback: (error: Error, content?: string) => void) {
get(url, (error, response) => {
if (error) {
callback(error);
} else {
writeFile(saveTo, response.body, (error) => {
if (error) {
callback(error);
} else {
callback(null, response.body);
}
});
}
});
}
downloadPage('https://en.wikipedia.org/wiki/Robert_Cecil_Martin', 'article.html', (error, content) => {
if (error) {
console.error(error);
} else {
console.log(content);
}
});
これをまずは Promise で書き直してみる。
Good:
import { get } from 'request';
import { writeFile } from 'fs';
import { promisify } from 'util';
const write = promisify(writeFile);
function downloadPage(url: string, saveTo: string): Promise<string> {
return get(url)
.then(response => write(saveTo, response));
}
downloadPage('https://en.wikipedia.org/wiki/Robert_Cecil_Martin', 'article.html')
.then(content => console.log(content))
.catch(error => console.error(error));
async / await を使うとさらに Good:
import { get } from 'request';
import { writeFile } from 'fs';
import { promisify } from 'util';
const write = promisify(writeFile);
function downloadPage(url: string, saveTo: string): Promise<string> {
return get(url)
.then(response => write(saveTo, response));
}
try {
const content = await downloadPage('https://en.wikipedia.org/wiki/Robert_Cecil_Martin', 'article.html');
console.log(content);
} catch (error) {
console.error(error));
}
終わりに
いかがでしたでしょうか。
「もう全部知っているよ」という強者もいるかもしれません。
自分はよく忘れてしまうので定期的に見返しています。
他にも「こういう章があってもいいんじゃないか」「こういう書き方もできるんじゃないか」という意見があればお願いします。
それではまた。