DEV Community

kaede
kaede

Posted on • Updated on

SOLID 原則 を Java ではなく TypeScript で 学んでみる

why

https://www.membersedge.co.jp/blog/typescript-solid-dependency-inversion-principle/

依存性逆転の話が面白くて、他も知りたくなった

Java は得意ではないから倦厭していたが、
membersedge さんの記事は TS で書かれていた。
なので SOLID 一つづつ読み解いてみた。


1. S: Single Responsibility -- 単一責任

https://www.membersedge.co.jp/blog/typescript-solid-single-responsibility-principle/

一つのクラスに一つの責任にする。
別のクラスに 2 つ以上のクラスが依存すると
そのクラスは複数の責任を持つことになってしまうので。

こちらの

  • 給与計算
  • 労働時間レポート
  • 所定労働時間

これらのメソッドを内蔵するクラスだと

給与計算と労働時間レポートが、所定労働時間のメソッドに依存してしまう

これだと給与計算の算出を変える時に所定労働時間の計算方法を変えると
労働時間レポートが正しく計算されない可能性がある

なので

  • 給与支払い計算
  • 労働時間レポート

これらの内部に所定労働時間の関数をそれぞれ入れてしまう。

すると給与計算を変更する時に労働時間レポートがおかしくなることはなくなる


2. O: Open-Closed -- 開放閉鎖

https://www.membersedge.co.jp/blog/typescript-solid-open-closed-principle/

コードを追加する拡張については開放されていて
既存のコードを修正する拡張について閉鎖されている

動作しているコードを変えるとバグを産む可能性が高いかららしい。

type Position = "Intern" | "Staff" | "Manager";

  private addSalaryToTotalPayment = (): void => {
    switch (this.employee.position) {
      case "Intern":
        this.totalPayment += this.BASE * 0.5;
        break;
      case "Staff":
        this.totalPayment += this.BASE;
        break;
      case "Manager":
        this.totalPayment += this.BASE * 2;
        break;
    }
  };
Enter fullscreen mode Exit fullscreen mode

このコードだと Position を追加するときに
型もケースもコードを追加しなければいけない

export class Intern implements Employee {
  public constructor(public name: string) {}
  public getSalary = (base: number) => base * 0.5;
}
export class Staff implements Employee {
  public constructor(public name: string) {}
  public getSalary = (base: number) => base;
}
Enter fullscreen mode Exit fullscreen mode

このように職種ごとにクラスを作って
内部に給与計算をそれぞれ作る。

これで新しくクラスを作るのに関しては開放して
既存のクラスを変更するのには閉鎖できた。


一時まとめ

S でも O でも
共通クラスを削って
重複があっても責務を分離するためにクラスをたくさん作るようにする。


3. L: Liskov Substitution -- リスコフ置換

https://www.membersedge.co.jp/blog/typescript-solid-liskov-substitution-principle/

親のプロパティの数 < 子供のプロパティの数

にする。逆だと拡張可能にならない。

IRectangle から Rectangle と Square を作る

interface IRectangle {
  setWidth: (width: number) => IRectangle;
  setHeight: (height: number) => IRectangle;
  getArea: () => number;
}
Enter fullscreen mode Exit fullscreen mode

width と height の引数を取って area を算出する IRectangle
という長方形の interface がある

IRectangle を implement して Rectangle という長方形クラスを作る

そしてさらに、IRectangle を implement して Square という
height x height の正方形クラスを作る。

すると Square はテストに落ちてしまう。
インターフェースで用意された width を使っていないからだ。

IShape から Rectangle と Square を作る

interface IShape {
  getArea: () => number;
}
Enter fullscreen mode Exit fullscreen mode

なので interface を面積の出力だけがある IShape にして

class Rectangle implements IShape {
  private width: number;
  private height: number;
}

class Square implements IShape {
  private length: number;
}
Enter fullscreen mode Exit fullscreen mode

IShape インターフェース を implement して
Rectangle クラス width と height
Square クラスで length
これらを各クラスの内部に定義する。

このように

「親の決まりを子が破ってはいけない」

規則が守れた。


4. I: Interface Segregation -- インターフェイス分離

https://www.membersedge.co.jp/blog/typescript-solid-interface-segregation-principle/

export type Task = {
  id: string;
  title: string;
  details: string;
// ...
}
Enter fullscreen mode Exit fullscreen mode

id, title, detail などと型が入っているインターフェースがある

const updateTaskTitle = async (
  task: Task,
  updateTitle: (props: {id: string, title: string} => { 
//...
}
Enter fullscreen mode Exit fullscreen mode

Task のオブジェクトをとって、その上で中身の id と title のみを
updateTitle という内部の関数で使っている

type Task = {
  id: string;
  taskInfo: {
    title: string;
    details: string;
  },
Enter fullscreen mode Exit fullscreen mode

しかし、これだと構造が変わった時に使えなくて問題になる

const updateTaskTitle = async (
  task: {id: string, title: string},
  updateTitle: (props: {id: string, title: string}) => {
// ...
}
Enter fullscreen mode Exit fullscreen mode

なので、分割代入で task の id と title をとることで
前述の taskInfo に title が入っている interface の構造でも

updateTaskTitle.updateTitle(
  task.id,
  task.taskInfo.title
)
Enter fullscreen mode Exit fullscreen mode

このように使うときに id と taskInfo の title を別々に使うことができる

インターフェース分離の原則まとめ

よってインターフェース分離の原則とは
インターフェースの構造が変わっても利用側が困らないように
利用側で引数を分割代入などで自由に取れることだと解釈する。


5. D: Dependency Inversion -- 依存性逆転

通常は

export const fetchUser = async () => {
  try {
    const response = await fetch("/api/user");
    return response;
  } catch (error) {
    throw new Error(error);
  }
};
Enter fullscreen mode Exit fullscreen mode

fetchUser という fetch を使って user の API を叩いて
結果を返すコンポーネントがあり

export type User = {
  id: string;
  name: string;
  // etc...
};

import { fetchUser } from "path/to/fetch-user";
const getUserName = async () => {
  const response = await fetchUser();
  // fetchの`response.json()`の型がPromise<any>
  // であるため、自前のwrapperで型をつける
  const user: User = await validateType<User>(response.json());
  return user.name;
};
Enter fullscreen mode Exit fullscreen mode

getUserName という fetchUser を使って
User 型をつけて user.name を返すコンポーネントがある

import axios from "axios";
export const fetchUser = async (): Promise<User> => {
  try {
    const response = await axios.get<User>("/api/user");
    const user = response.data;
    return user;
  } catch (error) {
    throw new Error(error);
  }
Enter fullscreen mode Exit fullscreen mode

fetchUser コンポーネントをこのように

await fetch("/api/user");
Enter fullscreen mode Exit fullscreen mode

fetch から

await axios.get<User>("/api/user");
Enter fullscreen mode Exit fullscreen mode

axios.get で型をつけて response.data で返すようにする

こうすると axios.get で返した場合は response.json で返せない。
なので getUserName が json 無いぞエラーになってしまう。

interface IFetchUser {
  fetchUser: () => Promise<User>;
}
Enter fullscreen mode Exit fullscreen mode

なので IFetchUser という
単純に関数で Promise の User 型を返すだけの
インターフェースを作成する

const getUserName = async ({ fetchUser }: IFetchUser) => {
  const user: User = await fetchUser();

  return user.name;
};
Enter fullscreen mode Exit fullscreen mode

getUserName で 引数に IfetchUser 型チェックをつけて
fetchUser を呼び出すようにする

export const fetchUser = async (): Promise<User> => {
  try {
    const response = await fetch("/api/user");
    // fetchの`response.json()`の型がPromise<any>であるため、自前のwrapperで型をつける
    const user = validateType<User>(response.json());

    return user;
  } catch (error) {
    throw new Error(error);
  }
};
Enter fullscreen mode Exit fullscreen mode

その fetchUser では
内部で fetch を使って response.json を返す

こうすることで fetch を axios.get に変えるときに

getUserNmae が IFetchUser を使って fetchUser を呼ぶときに
fetchUser を変更するだけで済むので

コードの保守性が高くなると解釈する。

5 まとめ

FetchUser という api/user を fetch() で呼び
response を返すコンポーネントがあり

getUserName という FetchUser の response の json の
user の値を返すコンポーネントがある。

この場合は FetchUser で axios.get() に変更すると
getUserName で response で json が取れなくなってエラーになる

これは設計が良くない。

FetchUser で api/user を fetch() して
その response の json まで返すように変更する。

IFetchUser という中身が関数でかつ Promise になっている
インターフェースを作る。

getUserName では fetchUser を引数で受け取り
IFetchUser でチェックし
fetchUser の中身の name を取り出す。

最小限のインターフェースを挟むことで、
親も子供もそれを意識して実装できるので、
自然と自分の役割に集中したコンポーネントになる

こういう原則だと解釈した。


Solid 総合まとめ

Single Responsibility では
A, B, C と並列するクラスがあったときに A と C が B を使っている時
B のロジックを A と C に内包すべきという原則。

Open-Closed では
1 つのクラスの内部で A, B, C とケースごとの処理がある時
それぞれ別のクラスとすることで
B を変更するときに A と C に影響が出ないことを保証する原則。

Liskov Substitution では
四角形の面積を計算する関数で
四角形専用 width と height があるインターフェースを使っていると
width しか使わない正方形関数が出た時に困る。

四角形でも正方形でも使えるインターフェースを作ることで
正方形関数を実装するときにインターフェースも四角形関数も
変更しなくて済むようになる原則。

Interface Segregation では
インターフェースを使う関数で
引数を一つ一つ分割代入で取るようにすることで
インターフェースの変更に強くなる原則。

Dependency Inversion では
get などの処理で、呼ばれる側(子供)と呼び出す側(親)の関数の間に
シンプルなインターフェースを挟むことで
親も子供も自分の責務を相手に任せず実装できる原則。

以上。

Discussion (0)