30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

恒等関数と extends キーワードを使った TypeScript のテクニック

この記事の内容は TypeScript のv4.1.3で、compilerOptions.noUncheckedIndexedAccessを有効にした状態で動作確認している。

参考: zenn.dev

恒等関数(Identity Function)とは、渡されたものを返す関数。

function identity<T>(arg: T) {
  return arg;
}

const x = identity(1); // const x: 1
const y = identity(() => 1); // const y: () => 1

引数をそのまま返しているため当然だが、値は変わらない。

このままだと何の意味もないが、extendsキーワードを使って型に制約を与えることができる。
例えば以下のidentityには、ReturnNumberかそのサブタイプしか渡せない。

type ReturnNumber = () => number;
const identity = <T extends ReturnNumber>(arg: T) => arg;

identity(() => 1); // Ok
identity(() => 'a'); // Error
identity((x: number) => x); // Error

これだけでは変数名: ReturnNumberのようにアノテートするのと、変わらないように見える。
だが以下のケースでは、xyで型が異なっている。

type ReturnNumber = () => number;
const identity = <T extends ReturnNumber>(arg: T) => arg;

const x = identity((): 1 => 1);
const y: ReturnNumber = (): 1 => 1;

type Foo = typeof x; // () => 1
type Bar = typeof y; // () => number

yにはReturnNumberとアノテートしているので、yの型はReturnNumber(つまり() => number)になる。
だがxに対してはアノテートしていないので、identityに渡した(): 1 => 1がそのまま返ってきて代入されるので、() => 1になる。

このように恒等関数とextendsキーワードを活用することで、値に制限を加えつつ、本来の型を保つことができる。
このテクニックを使うことで、従来は難しかった表現が可能になる。

例えば以下のxObjという制約を満たしている。
それでいてidentityに渡されたオブジェクトリテラルの型がそのまま保持されるので、keyof typeof xで具体的な情報を取れるし、プロパティへのアクセスも適切に機能する。

type Value = number;
type Obj = Record<string, Value>;
const identity = <T extends Obj>(arg: T) => arg;

const x = identity({
  one: 1,
  two: 2,
  three: 3,
});

type Foo = keyof typeof x; // "one" | "two" | "three"

x.one; // number
x.foo; // Error

そしてObjの制約に違反するようなフィールドを追加すると、TypeScript がエラーを出す。

const x = identity({
  one: 1,
  two: 2,
  three: 3,
  four: '4', // Error
});

同様のことを恒等関数を使わずに実現しようとすると、かなり難しくなる。

まず、xに対して何もアノテートをつけないと、当然のように何の制約も与えられない。

const x = {
  one: 1,
  two: 2,
  three: 3,
  four: '4', // Error にならない
};

なので、Objでアノテートしてみる。
そうすると、four: '4'のようなフィールドを加えようとした時に、TypeScript がエラーを出してくれるようになる。

しかし今度は、Fooから詳細な情報が失われ、stringになってしまった。
また、全てのプロパティへのアクセスがnumber | undefinedになってしまった。
これは先程のidentityを使ったケースに比べて、明らかに使い勝手が悪くなっている。

type Value = number;
type Obj = Record<string, Value>;

const x: Obj = {
  one: 1,
  two: 2,
  three: 3,
};

type Foo = keyof typeof x; // string

x.one; // number | undefined
x.foo; // number | undefined

以下のようにKeyを用意することで、同等の使い勝手を取り戻せる。

type Key = "one" | "two" | "three";
type Value = number;
type Obj = Record<Key, Value>;

const x: Obj = {
  one: 1,
  two: 2,
  three: 3,
};

type Foo = keyof typeof x; // "one" | "two" | "three"

x.one; // number
x.foo; // Error

だがこの場合、フィールドを追加する度にKeyxの両方に記述しないといけない。

@@ -1,4 +1,4 @@
-type Key = "one" | "two" | "three";
+type Key = "one" | "two" | "three" | "four";
 type Value = number;
 type Obj = Record<Key, Value>;

@@ -6,4 +6,5 @@
   one: 1,
   two: 2,
   three: 3,
+  four: 4,
 };

どちらか一方にだけ記述するとエラーになるため記述し忘れることはないだろうが、手間であることには変わりない。
同じ情報を二箇所で管理することになってしまっているわけで、identityを使ったケースのほうが正規化されており望ましいように思える。

参考資料

React 要素を props で渡すことで不要な再レンダリングを回避する

この記事の内容は、React のv17.0.2で動作確認している。

React コンポーネントが入れ子になっているとき、親のコンポーネントが再レンダリングされると、子のコンポーネントも再レンダリングされる。stateの受け渡し等があるかどうかは関係ない。
そのため以下のコードの場合、ボタンを押下するとParentだけでなくChildも再レンダリングされ、両方のログが流れる。

import {useState} from 'react';
import {render} from 'react-dom';

function Child() {
  console.log('render Child');

  return <div>Child</div>;
}

function Parent() {
  const [state, setState] = useState(0);
  const onClick = () => {
    setState((s) => s + 1);
  };

  console.log('render Parent');

  return (
    <div>
      <button type="button" onClick={onClick}>
        count up
      </button>
      <br />
      {state}
      <Child />
    </div>
  );
}

function Container() {
  return <Parent />;
}

render(<Container />, document.querySelector('#container'));

だがChildParentstateの値がなんであれ表示する内容は変わらないため、Childの再レンダリングは不要である。
この不要な再レンダリングを回避する方法のひとつに、React.memoを使ったメモ化がある。

const Child = memo(() => {
  console.log('render Child');

  return <div>Child</div>;
});

メモ化については、以下の記事に詳しく書いた。

numb86-tech.hatenablog.com

これ以外の方法として、「Childコンポーネントが返す React 要素をParentpropsとして渡す」というものがある。

function Parent({children}) {
  const [state, setState] = useState(0);
  const onClick = () => {
    setState((s) => s + 1);
  };

  console.log('render Parent');

  return (
    <div>
      <button type="button" onClick={onClick}>
        count up
      </button>
      <br />
      {state}
      {children}
    </div>
  );
}

function Container() {
  return (
    <Parent>
      <Child />
    </Parent>
  );
}

こうすると、Parentが再レンダリングされてもChildは再レンダリングされない。

props.childrenではなく任意のpropsに React 要素を渡してもよい。

function Parent({originalProps}) {
  const [state, setState] = useState(0);
  const onClick = () => {
    setState((s) => s + 1);
  };

  console.log('render Parent');

  return (
    <div>
      <button type="button" onClick={onClick}>
        count up
      </button>
      <br />
      {state}
      {originalProps}
    </div>
  );
}

function Container() {
  return <Parent originalProps={<Child />} />;
}

これでも、同様の結果になる。

考えてみれば当然で、コンポーネント(関数)ではなくて要素(関数の返り値)を外から渡しているのだから、Parentが再レンダリングされたところで、渡されている要素はそのまま同じものが使われる。
以下のコードではParentのレンダリング毎のoriginalPropslistに入れているが、list内の全ての要素は常に同じものを参照している。

import {useState} from 'react';
import {render} from 'react-dom';

function Child() {
  console.log('render Child');

  return <div>Child</div>;
}

const list = [];

function Parent({originalProps}) {
  const [state, setState] = useState(0);
  const onClick = () => {
    setState((s) => s + 1);
  };

  console.log('render Parent');

  list.push(originalProps);
  if (list.length >= 2) {
    console.log(list.every((elem) => elem === originalProps)); // true
  }

  return (
    <div>
      <button type="button" onClick={onClick}>
        count up
      </button>
      <br />
      {state}
      {originalProps}
    </div>
  );
}

function Container() {
  return <Parent originalProps={<Child />} />;
}

render(<Container />, document.querySelector('#container'));

そのため、要素ではなくコンポーネントをpropsで渡してしまうと、再レンダリングは回避できない。

以下のコードでは、要素ではなくChildコンポーネントをpropsで渡し、Parentのなかで要素を作成している。
この場合、Parentが再レンダリングされる度にChildが呼び出され、新しく要素を作ることになる。
つまり、Parentが再レンダリングされるとChildも再レンダリングされるということである。

import {useState} from 'react';
import {render} from 'react-dom';

function Child() {
  console.log('render Child');

  return <div>Child</div>;
}

const componentList = [];
const elementList = [];

function Parent({OriginalProps}) {
  const [state, setState] = useState(0);
  const onClick = () => {
    setState((s) => s + 1);
  };

  console.log('render Parent');

  const element = <OriginalProps />; // 渡されたコンポーネントから要素を作成している

  componentList.push(OriginalProps);
  if (componentList.length >= 2) {
    console.log(componentList.every((elem) => elem === OriginalProps)); // true
  }

  // どの要素も`<div>Child</div>`ではあるが、別々の`<div>Child</div>`が都度作られている
  elementList.push(element);
  if (elementList.length >= 2) {
    console.log(elementList.every((elem) => elem === element)); // false
  }

  return (
    <div>
      <button type="button" onClick={onClick}>
        count up
      </button>
      <br />
      {state}
      {element}
    </div>
  );
}

function Container() {
  return <Parent OriginalProps={Child} />; // 要素ではなくてコンポーネントを渡している
}

render(<Container />, document.querySelector('#container'));

参考資料