Reincarnated as anyone

フロントエンド学習メモ

【学習メモ】 TypeScript の型入門 その2

はじめに

@uhyo_さんが書いた以下の記事を理解するために、原文を自分でも理解できる表現にリライトしたものになります。

TypeScript の型入門

この記事はどんな記事?

TypeScript(v3.7) の型について初歩から解説した記事。

  • ジェネリクス
  • タプル型
  • union 型(合併型)
  • never 型
  • intersection 型(交差型)

ジェネリクス

以下のように、型名のあとに<>で囲った名前を追加することで、型の定義の中でそれらの名前を型変数として利用できる。

// `Foo`は2つの型変数`S`、`T`を持つ
interface Foo<S, T> {
  foo: S;
  bar: T;
}

// `Foo<number, string>`と記述しているので、
// `S`型は`number`型になり、`T`型は`string`型になる。
const obj: Foo<number, string> = {
  foo: 3,
  bar: "hi"
};

他にも、以下のようにクラス定義や関数定義でも型変数を導入できる。

class Foo<T> {
  constructor(arg: T) {}
}

const obj1 = new Foo<string>("foo");

function func<T>(obj: T): void {}

func<number>(3);

また、関数の引数は型推論されるため、以下のように関数の型引数は省略できる。

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

// identity に渡した引数 value から T 型が推論される。
// 3 を渡しているので、`T`型は`number`型になる。
const value = identity(3);

// エラー: Type '3' is not assignable to type 'string'.
const str: string = value;

ただし、複雑なことをする場合は型変数が推論できないこともある。

タプル型

以下のような[string, number]がタプル型。

// 長さが 2 の配列で、0 番目に文字列が、1 番目に数値が入る。ということを表現している。
const foo: [string, number] = ["foo", 5];

const str: string = foo[0];

function makePair(x: string, y: number): [string, number] {
  return [x, y];
}

TypeScript がタプルと呼んでいるものはあくまで配列のため、配列のメソッドで操作できる。

以下のコードはエラーが発生しないが、number 型の変数numには文字列が入ってしまう。

const tuple: [string, number] = ["foo", 3];

tuple.pop();
// tuple[1] に string 型が入ってしまうが、エラー発生しない。
tuple.push("Hey!");

// tuple[1] に string 型が入っているが、エラー発生しない。
const num: number = tuple[1];

このあたりは TypeScript の型システムの限界なので、タプル型を使用するときは注意するか、そもそもこのようにタプル型を使うのは避けたほうが良いかもしれない。

以下のように要素が 0 個のタプル型なども作ることができる。

const unit: [] = [];

また、TypeScript のタプル型は、以下のように可変長のタプル型の宣言が可能。

type NumAndStrings = [number, ...string[]];

const a1: NumAndStrings = [3, "foo", "bar"];
const a2: NumAndStrings = [5];
// エラー: Type 'string' is not assignable to type 'number'.
const a3: NumAndStrings = ["foo", "bar"];

しかし、...を使えるのはタプル型の最後に 1 回だけであり、以下のような型は宣言できない。

type NumAndStrings = [...number[], ...string[]];

また、以下のように?がついた、オプショナルな要素を持つタプル型も定義できる。

type T = [string, number?];

const t1: T = ["foo"];
const t2: T = ["foo", 3];

オプショナルな要素は複数あっても問題ないが、オプショナルではない要素より後に来なければいけない。

そのため、[string?, number]のような型は NG。

タプル型と可変長引数

TypeScript 3.0 から、関数の可変長引数の型にタプル型を利用できるようになった。

type Args = [string, number, boolean];

// func の型は (args_0: string, args_1: number, args_2: boolean) => number になる
const func = (...args: Args) => args[1];

const v = func("foo", 3, true);

上記のようにタプル型を用いることによって、複数の引数の型をまとめて指定できる。

以下のように、可変長タプルを用いた場合、引数の可変長性が保たれる。

type Args = [string, ...number[]];

// func の型は (f: string, args_0: string, ...args_1: number[]) => string
const func = (f: string, ...args: Args) => args[0];

const v1 = func("foo", "bar");
const v2 = func("foo", "bar", 1, 2, 3);

また、以下のようにオプショナルな要素を持つタプルの型を用いた場合、オプショナルな引数を持つ関数の型ができる。

type Args = [string, number?];

// func の型は (f: string, args_0: string, args_1?: number | undefined) => string
const func = (f: string, ...args: Args) => args[0];

タプル型と可変長引数とジェネリクス

タプル型をとるような型変数を用いることで、関数の引数列をジェネリクスで扱うことができる。

例として、関数の最初の引数があらかじめ決まっている関数を作る、関数bindを書いてみる。

// U は any[] 型であることを制約する
function bind<T, U extends any[], R>(
  // bind の第1引数に渡された関数 func の第2引数以降の型が U 型になり、関数の戻り値の型が R 型になる。
  // 例
  // const add = (x: number, y: number) => x + y;
  // const add1 = bind(add, 1);
  // bind に渡している add の第2引数 y の型は number なので、U の型は [number] 型になる。
  // また、add の戻り値の型は number 型なので、R 型は number になる。
  func: (arg1: T, ...rest: U) => R,

  // bind の第2引数に渡された型が T型になる。
  // 例
  // bind(add, 1) の場合、T型が 1 になる。
  value: T
): (...args: U) => R {
  return (...args: U) => func(value, ...args);
}

const add = (x: number, y: number) => x + y;
// function bind<1, [number], number>(func: (arg1: 1, y: number) => number, value: 1): (y: number) => number
const add1 = bind(add, 1);

console.log(add1(5)); // 6

// Argument of type '"foo"' is not assignable to parameter of type 'number'.
add1("foo");

union 型(合併型)

以下のように、複数の型のどれかに当てはまるような型を union 型と言う。

let value: string | number = "foo";
value = 100;
value = "bar";

// エラー: Type 'true' is not assignable to type 'string | number'.
value = true;

プリミティブ型だけでなくオブジェクトの型でも union 型を作ることができる。

interface Hoge {
  foo: string;
  bar: number;
}
interface Piyo {
  foo: number;
  baz: boolean;
}

type HogePiyo = Hoge | Piyo;

const obj: HogePiyo = {
  foo: "hello",
  bar: 0
};
const obj: HogePiyo = {
  foo: 1,
  baz: false
};
// エラー
const obj3: HogePiyo = {
  hoge: "hello"
};

union 型の絞り込み

上記のように、Hoge | Piyoのような型の値が与えられる場合、実行時に値がどちらの型か判定する必要がある。

判定をしないと、以下のようにエラーが出る。

interface Hoge {
  foo: string;
  bar: number;
}
interface Piyo {
  foo: number;
  baz: boolean;
}

function useHogePiyo(obj: Hoge | Piyo): void {
  // この時点では obj が Hoge型 か Piyo型かわからないので、
  // obj には bar プロパティが存在しない可能性があり、エラーが出る。
  console.log(obj.bar);
}

以下のように、in演算子を使った if 文を書くことで、適切に型を絞り込んでくれる。

interface Hoge {
  foo: string;
  bar: number;
}
interface Piyo {
  foo: number;
  baz: boolean;
}

function useHogePiyo(obj: Hoge | Piyo): void {
  // ここではobjはHoge | Piyo型
  if ("bar" in obj) {
    // barプロパティがあるのはHoge型なのでここではobjはHoge型
    console.log("Hoge", obj.bar);
  } else {
    // barプロパティがないのでここではobjはPiyo型
    console.log("Piyo", obj.baz);
  }
}

ただし、この機能を利用すると、以下のようなコードを書けてしまうので注意が必要。

// 余計な bar プロパティがついたオブジェクト
// 本来これは Piyo 型として扱いたいが、useHogePiyo に渡すと、
// "bar" in obj で true になるので、Hoge 型として扱われてしまう。
const obj: Hoge | Piyo = {
  foo: 123,
  bar: "bar",
  baz: true
};

function useHogePiyo(obj: Hoge | Piyo): void {
  // ここではobjはHoge | Piyo型
  if ("bar" in obj) {
    // barプロパティがあるのはHoge型なのでここではobjはHoge型
    console.log("Hoge", obj.bar);
  } else {
    // barプロパティがないのでここではobjはPiyo型
    console.log("Piyo", obj.baz);
  }
}

useHogePiyo(obj);

typeof を用いた絞り込み

オブジェクトではなく、string | numberのような型の場合、typeof演算子で絞り込みできる。

function func(value: string | number): number {
  if ("string" === typeof value) {
    // valueはstring型なのでlengthプロパティを見ることができる
    return value.length;
  } else {
    // valueはnumber型
    return value;
  }
}

null チェック

それは nullable な値を扱いたい場合も、union 型を用いる。

例えば、文字列の値があるかもしれないし null かもしれないという状況はstring | nullという型で表現できる。

このような型を絞り込みたい場合は、以下のように if 文を利用する。

function func(value: string | null): number {
  if (value != null) {
    // valueはnullではないのでstring型に絞り込まれる
    return value.length;
  } else {
    return 0;
  }
}

また、&&||を利用した絞り込みもできる。

そのため、上記のfuncは以下のようにも書ける。

function func(value: string | null): number {
  return (value != null && value.length) || 0;
}

タグ付き union 型

以下のように、リテラル型で区別できる union 型をタグ付き union 型と言う。

interface Some<T> {
  type: "Some";
  value: T;
}
interface Any {
  type: "Any";
}
interface None {
  type: "None";
}
// Option<T> 型は Some<T> 型と None 型の union 型。
// Some<T> 型と None 型を共通プロパティである type を保持している。
// type の型は 'Some'型 と 'None'型であり、それぞれリテラル型である。
// そのため、リテラル型で Option<T> 型が、Some<T> 型なのか None 型なのかを区別できる。
// このようにリテラル型で区別できる型をタグ付き union 型と言う。
type Option<T> = Some<T> | None;

function map<T, U>(obj: Option<T>, f: (obj: T) => U): Option<U> {
  if (obj.type === "Some") {
    // obj は Some<T> 型と推論される。
    return {
      type: "Some",
      value: f(obj.value)
    };
  } else {
    return {
      type: "None"
    };
  }
}

次のように switch 文でも同じことをできる。

function map<T, U>(obj: Option<T>, f: (obj: T) => U): Option<U> {
  switch (obj.type) {
    case "Some":
      return {
        type: "Some",
        value: f(obj.value)
      };
    case "None":
      return {
        type: "None"
      };
  }
}

今回のmap関数の場合、こちらのほうが拡張に対して強くて安全である。

例えば、以下のようにOption<T>Any型を追加しても if 文の場合はエラーが発生しない。

interface Some<T> {
  type: "Some";
  value: T;
}
interface Any {
  type: "Any";
}
interface None {
  type: "None";
}

type Option<T> = Some<T> | Any | None;

function map<T, U>(obj: Option<T>, f: (obj: T) => U): Option<U> {
  if (obj.type === "Some") {
    return {
      type: "Some",
      value: f(obj.value)
    };
  } else {
    return {
      type: "None"
    };
  }
}

一方で、switch 文の場合はコンパイルエラーが出る(Any型に対する処理が定義されておらずmap関数が値を返さない可能性が生じてしまうため)。

interface Some<T> {
  type: "Some";
  value: T;
}
interface Any {
  type: "Any";
}
interface None {
  type: "None";
}

type Option<T> = Some<T> | Any | None;

// エラー
// Function lacks ending return statement and return type does not include 'undefined'.ts(2366)
function map<T, U>(obj: Option<T>, f: (obj: T) => U): Option<U> {
  switch (obj.type) {
    case "Some":
      return {
        type: "Some",
        value: f(obj.value)
      };
    case "None":
      return {
        type: "None"
      };
  }
}

union 型オブジェクトのプロパティ

オブジェクト同士の union 型を作った場合、そのプロパティアクセスの挙動は概ね期待通りの結果となる。

interface Hoge {
  foo: string;
  bar: number;
}
interface Piyo {
  foo: number;
  baz: boolean;
}

type HogePiyo = Hoge | Piyo;

function getFoo(obj: HogePiyo): string | number {
  // obj.foo は string | number型
  return obj.foo;
}

配列の要素もオブジェクトのプロパティの一種なので、同じ挙動となる。

const arr: string[] | number[] = [];

// string[] | number[] 型の配列の要素は string | number 型
const elm = arr[0];

never 型

属する値が存在しない型。

以下のように、どんな値も never 型の変数に入れることはできない。

// エラー: Type '0' is not assignable to type 'never'.
const n: never = 0;

逆に、以下のように never 型の値はどんな型にも入れることができる。

// never 型の値を作る方法が無いので declare で宣言だけする
declare const n: never;

const foo: string = n;

never 型が出現するタイミング

interface Some<T> {
  type: "Some";
  value: T;
}
interface None {
  type: "None";
}
type Option<T> = Some<T> | None;

function map<T, U>(obj: Option<T>, f: (obj: T) => U): Option<U> {
  switch (obj.type) {
    case "Some":
      return {
        type: "Some",
        value: f(obj.value)
      };
    case "None":
      return {
        type: "None"
      };
    default:
      // 上記の case 文 によって obj の推論はすべて調べつくしている。
      // この switch 文の中では、obj の値の候補がまったくない。それを表すために obj が never 型になる。
      return obj;
  }
}

以下のように関数の返り値でも never 型が出てくることもある。

// 関数が値を返す可能性が無い時は nerver 型になる。
// 返り値がないことを表す void 型とは異なり、関数が正常に終了して、値が返ってくるということがあり得ない場合を表す。
// 戻り値を型注釈をしないと void 型が推論されるので、nerver 型を明示する必要がある。
function func(): never {
  throw new Error("Hi");
}

// result は never 型(result に何かが代入されることはあり得ない)
const result = func();

intersection 型(交差型)

intersection 型は、以下のT & Uのような、TでもありUでもあるような型のこと。

interface Hoge {
  foo: string;
  bar: number;
}
interface Piyo {
  foo: string;
  baz: boolean;
}

// obj の型は以下の型になる
// {
//   foo: string;
//   bar: number;
//   baz: boolean;
// }
const obj: Hoge & Piyo = {
  foo: "foooooooo",
  bar: 3,
  baz: true
};

以下のように union 型と intersection 型を組みあわせることも可能。

interface Hoge {
  type: "hoge";
  foo: string;
}
interface Piyo {
  type: "piyo";
  bar: number;
}
interface Fuga {
  baz: boolean;
}

// Obj は以下のいずれかの型になる
// {
//   type: "hoge";
//   foo: string;
//   baz: boolean;
// }
// {
//   type: "piyo";
//   bar: number;
//   baz: boolean;
// }
type Obj = (Hoge | Piyo) & Fuga;
// ↑は type Obj = (Hoge & Fuga) | (Piyo & Fuga) とも書ける

function func(obj: Obj) {
  // obj は必ず Fuga 型なので、baz を参照可能
  console.log(obj.baz);
  if (obj.type === "hoge") {
    // Hoge & Fuga 型が推論される
    console.log(obj.foo);
  } else {
    // Piyo & Fuga 型が推論される
    console.log(obj.bar);
  }
}

union 型を持つ関数との関係

前述の通り、関数型とそれ以外の型の union 型を作った場合、それを関数として呼ぶことはできない。

type Func = (arg: number) => number;
interface MyObj {
  prop: string;
}

const obj: Func | MyObj = { prop: "" };

// obj は MyObj(object)型の可能性があるため、エラーが発生する
// エラー: Cannot invoke an expression whose type lacks a call signature.
//        Type 'MyObj' has no compatible call signatures.
obj(123);

また、以下のように union 型の構成要素がすべて関数型でも、引数が intersection 型になり関数を呼べないこともある。

// 文字列を受け取って文字列を返す関数型
type StrFunc = (arg: string) => string;
// 数値を受け取って文字列を返す関数型
type NumFunc = (arg: number) => string;

declare const func: StrFunc | NumFunc;
// func の引数は string & number 型になっているため、数値を渡すとエラーが発生する。
// そもそも string & number 型をwたすことは不可能なため、この関数を呼ぶことはできない。
// エラー: Argument of type '123' is not assignable to parameter of type 'string & number'.
//        Type '123' is not assignable to type 'string'.
func(123);

上記の挙動は TypeScript 3.3 からの新しい挙動である。

それ以前は、異なる型の関数同士の union は関数として呼ぶことができなかった。

引数の型が intersection 型の場合、以下のような引数を渡せば関数を呼べる。

interface Hoge {
  foo: string;
  bar: number;
}
interface Piyo {
  foo: string;
  baz: boolean;
}

type HogeFunc = (arg: Hoge) => number;
type PiyoFunc = (arg: Piyo) => boolean;

// func の型は (arg: Hoge & Piyo) => number | boolean 型になる。
declare const func: HogeFunc | PiyoFunc;

// `Hoge & Piyo`型のオブジェクトを渡せば関数を呼べる。
const res = func({
  foo: "foo",
  bar: 123,
  baz: false
});

【学習メモ】 TypeScript の型入門 その1

はじめに

@uhyo_さんが書いた以下の記事を理解するために、原文を自分でも理解できる表現にリライトしたものになります。

TypeScript の型入門

この記事はどんな記事?

TypeScript(v3.7) の型について初歩から解説した記事。

  • プリミティブ型
  • リテラル
  • オブジェクト型
  • 配列型
  • 関数型
  • void 型
  • any 型
  • クラスの型

プリミティブ型

TypeScript において一番基本となる型。

これは JavaScript のプリミティブの型と対応しており、以下の型が存在する

  • string
  • number
  • boolean
  • symbol
  • bigint
  • null
  • undefined

これらは互いに異なる型であり、以下のように相互に代入はできない。

const a: number = 3;

const b: string = a; // エラー: Type 'number' is not assignable to type 'string'.

bigint

bigint型は TypeScript 3.2 で導入されたので、それ以降の TypeScript でのみ利用可能。

また、BigInt のトランスパイルはできないため、targettsconfig.jsonなどに設定できるオプション)をesnext(または ES2019 が登場したらes2019またはそれ以降)にしないと利用できない。

strictNullChecksオプション

コンパイラオプションで--strictNullChecksをオンにしていない場合は、以下のようにnullundefinedは他の型の値として扱うことができる。

const a: null = null;

const b: string = a; // strictNullChecksがオンならエラー

逆に言うと、--strictNullChecksをオンにしないとundefinednullが紛れ込む可能性があり危険。

そのため、このオプションは常にオンにするべき。

リテラル

プリミティブ型を細分化した型。

リテラル型には、以下のリテテル型が存在する。

例えば、'foo'というリテラル型は、'foo'という文字列しか許されない型である。

同様に、0というというリテラル型は、0という数値しか許されない型になる

const a: "foo" = "foo";
const b: "bar" = "foo"; // エラー: Type '"foo"' is not assignable to type '"bar"'.

文字列のリテラル型はstringの部分型であり、文字列のリテラル型を持つ値は、以下のように string 型として扱うこともできる。

const a: "foo" = "foo";
const b: string = a;

他の型も同様。

const a: 1 = 1;
const b: number = a;

const c: true = true;
const d: boolean = c;

リテラル型と型推論

上記の例では変数に全て型注釈を付けていたが、以下のように型注釈を省略してもちゃんと推論される。

const a = "foo"; // a は'foo'型を持つ
const b: "bar" = a; // エラー: Type '"foo"' is not assignable to type '"bar"'.

変数 a は'foo'が代入されているので、a の型も'foo'型になる。

ただし、これはconstを使って変数を宣言した場合に限る。

letvarで変数が宣言する場合、推論される型はリテラル型ではなく対応するプリミティブ型になる。

let a = "foo"; // a は string 型に推論される
const b: string = a;
const c: "foo" = a; // エラー: Type 'string' is not assignable to type '"foo"'.

上記の例では、aletで宣言されているのでstring型と推論される。

そのため、'foo'型を持つcに代入することはできない。

なお、letで宣言する場合も型注釈をつければリテラル型を持たせることはできる。

let a: "foo" = "foo";
a = "bar"; // エラー: Type '"bar"' is not assignable to type '"foo"'.

オブジェクト型

{}の中にプロパティ名とその型を列挙するものがオブジェクト型。

例えば、以下の型はfooというプロパティがstring型の値を持ちbarというプロパティがnumber型の値を持つようなオブジェクトの型。

interface MyObj {
  foo: string;
  bar: number;
}

const a: MyObj = {
  foo: "foo",
  bar: 3
};

上記のinterfaceは、TypeScript 独自の構文であり、オブジェクト型に名前を付けることができる。

この例では、{ foo: string; bar: number }という型にMyObjという名前を付けている。

また、今回はconstに型注釈を付けているが、型注釈をしなくてもオブジェクト型を推論してくれる。

型が合わないオブジェクトを変数に代入したりしようとすると、以下のように型エラーとなる。

interface MyObj {
  foo: string;
  bar: number;
}

// bar に string 型を定義しているので、エラー
// エラー:
// Type '{ foo: string; bar: string; }' is not assignable to type 'MyObj'.
//  Types of property 'bar' are incompatible.
//    Type 'string' is not assignable to type 'number'.
const a: MyObj = {
  foo: "foo",
  bar: "BARBARBAR"
};

// bar プロパティがないのでエラー
// エラー:
// Type '{ foo: string; }' is not assignable to type 'MyObj'.
//  Property 'bar' is missing in type '{ foo: string; }'.
const b: MyObj = {
  foo: "foo"
};

このように、TypeScript を利用することで、オブジェクトは自由に書き換えることを制限できる。

また、TypeScript では構造的部分型を採用しているため、次のようなことが可能。

interface MyObj {
  foo: string;
  bar: number;
}

interface MyObj2 {
  foo: string;
}

const a: MyObj = { foo: "foo", bar: 3 };
// `MyObj`型の値は`string`型のプロパティ`foo`を持っているため、
// `MyObj2`型の値の要件を満たしていると見なされる。
// このような状態を、`MyObj`は`MyObj2`の部分型であると言う。
const b: MyObj2 = a;

オブジェクトリテラルの特殊な処理

上記のようにMyObj型のaMyObj2型のbに代入をする時はエラーは発生しない。

しかし、以下のように、オブジェクトリテラルを代入する場合、余計なプロパティを持つオブジェクトは弾かれる。

interface MyObj {
  foo: string;
  bar: number;
}

interface MyObj2 {
  foo: string;
}

// `bar`プロパティは`MyObj2`型には存在しないので、エラーになる。
// エラー:
// Type '{ foo: string; bar: number; }' is not assignable to type 'MyObj2'.
//  Object literal may only specify known properties, and 'bar' does not exist in type 'MyObj2'.
const b: MyObj2 = { foo: "foo", bar: 3 };

配列型

配列の型を表すためには[]を利用する。

例えば、number[]というのは数値の配列を表す。

const foo: number[] = [0, 1, 2, 3];
foo.push(4);

また、TypeScript にジェネリクスが導入されて以降はArray<number>と書くことも可能(ジェネリクスについては後述)。

関数型

関数型は以下のように表現できる。

// 第 1 引数として`string`型の、第 2 引数として`number`型の引数をとり、
// 返り値として`boolean`型の値を返す関数の型
(foo: string, bar: number) => boolean;

function 宣言などによって作られた関数にも、以下のような関数型が付く。

const f: (foo: string) => number = func;

function func(arg: string): number {
  return Number(arg);
}

関数の部分型関係

関数型に対しては、普通の部分型関係がある。

interface MyObj {
  foo: string;
  bar: number;
}

interface MyObj2 {
  foo: string;
}

const a: (obj: MyObj2) => void = () => {};
// `(obj: MyObj2) => void`型の値を`(obj: MyObj) => void`型の値として扱うことが可能
// `MyObj`は`MyObj2`の部分型なので、`MyObj2`を受け取って処理できる関数は`MyObj`を
// 受け取っても当然処理できるだろうということを想定している。
// `a`と`b`の型を逆にするとエラーになる。
const b: (obj: MyObj) => void = a;

また、関数の場合、引数の数に関しても部分型関係が発生する。

const f1: (foo: string) => void = () => {};
// `(foo: string) => void`型の値を`(foo: string, bar: number) => void`型の値として使うことができる。
// すなわち、引数を1つだけ受け取る関数は、引数を2つ受け取る関数として使うことが可能であるということを想定している。
const f2: (foo: string, bar: number) => void = f1;

可変長引数

JavaScript には、以下のような可変長引数という機能がある。

const func = (foo, ...bar) => bar;

console.log(func(1, 2, 3)); // [2, 3]

TypeScript でも可変長引数の関数を宣言できる。

その場合、以下のように可変長引数の部分の型は配列にする。

// `...bar`に`number[]`型が付いているため、2番目以降の引数は全て数値でなければいけない
const func = (foo: string, ...bar: number[]) => bar;

func("foo");
func("bar", 1, 2, 3);
// エラー: Argument of type '"hey"' is not assignable to parameter of type 'number'.
func("baz", "hey", 2, 3);

void 型

「何も返さない」ことを表す型。関数の返り値の型として使われる。

以下のように何も返さない関数の返り値の型として void 型を利用する。

function foo(): void {
  console.log("hello");
}

// エラー: A function whose declared type is neither 'void' nor 'any' must return a value.
function bar(): undefined {
  console.log("world");
}

返り値が void 型である関数は値を返さなくてもよくなる。

逆に、それ以外の型の場合(any 型を除く)は必ず返り値を返さなければいけない。

any 型

何でもありな型。

TypeScript の型システムを無視するような型であり、TypeScrtipt を使う意味が薄れるので、やむを得ない場面でのみ利用する。

クラスの型

TypeScript では、クラスを定義すると同時に同名の型が定義される。

// `Foo`という、`Foo`クラスのインスタンスの型も同時に定義される
class Foo {
  method(): void {
    console.log("Hello, world!");
  }
}

const obj: Foo = new Foo();

obj: FooFooは型名のFooであり、new Foo()Fooはクラス(コンストラクタ)のFooである。

TypeScript はあくまで構造的型付けを採用しているので、クラスの構造が同じであれば別の型でも代替できる。

そのため、以下のように型Fooは次のようなオブジェクト型で代替可能。

interface MyFoo {
  method: () => void;
}

class Foo {
  method(): void {
    console.log("Hello, world!");
  }
}

// MyFoo も Foo と同じ method という関数型のプロパティを持つため、同じ型とみなす
const obj: MyFoo = new Foo();
const obj2: Foo = obj;

【学習メモ】 useReducer の本質:良いパフォーマンスのためのロジックとコンポーネント設計

はじめに

@uhyo_さんが書いた以下の記事を理解するために、原文を自分でも理解できる表現にリライトしたものになります。

useReducer の本質:良いパフォーマンスのためのロジックとコンポーネント設計

この記事はどんな記事?

どのような時にuseReducerを利用するのが適しているのかを説明した記事。

まとめ(記事の要約)

  • useReducerは、state に依存するロジックを、state に非依存な関数オブジェクト(dispatch)で表現できる。
  • state に依存するロジックを、state に非依存な関数オブジェクトで表現できることにより、React.memoによるパフォーマンス改善を行える。
  • useReducerを活かすには、state を1つにまとめることで、ロジックをなるべく reducer に詰め込む。

useReducerとは

関数コンポーネントで state 管理ができるようになるフック。

state 管理はuseStateuseReducerの 2 種類の方法があり、useReducerは複雑なロジックが絡んだ state を管理するのに適している。

useReducerの構文

const [currentState, dispatch] = useReducer(reducer, initialState);

引数として渡すのは以下の2つ。

  • reducer: 「現在の state」と「アクション」を受け取って「新しい state」を返す関数
  • initialState: state の初期値

useReducerの返り値は以下の2つ。

  • currentState: state の現在の値
  • dispatch: アクションを発火する関数

dispatchにアクションを渡すと、内部でreducerが呼び出されて新しい state が計算され、コンポーネントが再レンダリングされて新しい state が反映される。

reducerの例

const reducer = (state, action) => {
  if (action.type === "increment") {
    return {
      count: state.count + 1
    };
  } else {
    return {
      count: state.count - 1
    };
  }
};

受け取ったアクションに応じて、新しい state を返す。

この reducer では以下のアクションに応じた state を返す

  • { type: "increment" }: countを 1 増やしたstateを返す
  • { type: "decrement" }: countを 1 減らしたstateを返す

上記の reducer を以下のようにuseReducerに渡し、

const [state, dispatch] = useReducer(reducer, { count: 0 });

返り値であるdispatchに、以下のようにアクションを渡せば、state が更新される。

dispatch({ type: "increment" });

useReducerを利用することで、state を更新するロジックを reducer の1つに集約できる。

また、state の種類が増えたりロジックが増えたりしても、その操作の窓口がdispatchという1点に集約される。

コンポーネントが何かしらのロジックを発火したいときはdispatchを props で渡すだけでいいし、コンポーネントツリーが大きい場合はコンテキストを用いて子に伝えるのも有効。

useReducerがパフォーマンス改善につながる例

React アプリのパフォーマンス改善において大きな効果が出やすいのはReact.memoを活用すること(クラスコンポーネント時代のshouldComponentUpdatePureComponentに相当)。

これを活用してコンポーネントの余計な再レンダリングを避けることが、React アプリの基本的なパフォーマンス・チューニングとなる。

今回は、useReducerReact.memoの利用の助けになる例を見ていく。

初期状態のサンプル

まず、改善前の初期状態を見てみていく。

今回改善をするサンプルは、以下の機能を有している。

f:id:reincarnation777:20200907181111g:plain

  • 4 つの入力欄があり、それぞれに数値を入力することができる。
  • 入力欄の下には 4 つの数値を合計した値が表示される。
  • 入力欄の横にある「check」ボタンを押すと、そのときの数値が合計の何%かを一番下に表示する。
import React, { useState } from "react";

const sum = arr => arr.reduce((acc, cur) => acc + (Number(cur) || 0), 0);

const NumberInput = ({ value, onChange, onCheck }) => {
  return (
    <p>
      <input
        type="number"
        value={value}
        onChange={e => onChange(e.currentTarget.value)}
      />
      <button onClick={onCheck}>check</button>
    </p>
  );
};

export default function App() {
  const [values, setValues] = useState(["0", "0", "0", "0"]);
  const [message, setMessage] = useState("");

  return (
    <>
      {values.map((value, i) => {
        return (
          <NumberInput
            key={i}
            value={value}
            onChange={v =>
              setValues(current => {
                const result = [...current];
                result[i] = v;
                return result;
              })
            }
            onCheck={() => {
              const total = sum(values);
              const ratio = Number(value) / total;
              setMessage(
                `${value}は${total}の${(ratio * 100).toFixed(1)}%です`
              );
            }}
          />
        );
      })}
      <p>合計は{sum(values)}</p>
      <p>{message}</p>
    </>
  );
}

このコードは、ひとつの数値が変更されるたびに全てのNumberInputに再レンダリングが発生してしまう。

そのため、NumberInputReact.memoを適用して無駄な再レンダリングを減らしたい。

つまり、ひとつの数値が変更されたらそのNumberInputだけが再レンダリングされて、他のNumberInputは再レンダリングされないという状態が理想。

React.memo導入への努力

とりあえず、useStateのままでReact.memoの導入を試みる。

NumberInputReact.memoを適用して効果を得るためには、自分以外のNumberInputの入力値が変わっても props の内容が変化しないようにしなければいけない。

現状ではvalueは問題ないが、onChangeonCheckが問題。

<NumberInput
  // 省略...
  onChange={v =>
    // 省略...
  }
  onCheck={() => {
   // 省略...
  }}
/>

関数をベタ書きしているので、これらの props には毎回異なる関数オブジェクトが作られて渡されている。

そのため、React.memoは効かない。

毎回異なる関数オブジェクトが作られるのを防ぐために、useCallbackを利用する。

import React, { useState, useCallback } from "react";

const sum = arr => arr.reduce((acc, cur) => acc + (Number(cur) || 0), 0);

const NumberInput = ({ value, index, onChange, onCheck }) => {
  console.log("render NumberInput");
  return (
    <p>
      <input
        type="number"
        value={value}
        onChange={e => onChange(index, e.currentTarget.value)}
      />
      <button onClick={onCheck}>check</button>
    </p>
  );
};

export default function App() {
  const [values, setValues] = useState(["0", "0", "0", "0"]);
  const [message, setMessage] = useState("");

  const onChange = useCallback((index, value) => {
    setValues(values => {
      const newValues = [...values];
      newValues[index] = value;
      return newValues;
    });
  }, []);
  const onCheck = useCallback(
    index => {
      const total = sum(values);
      const ratio = Number(values[index]) / total;
      setMessage(
        `${values[index]}は${total}の${(ratio * 100).toFixed(1)}%です`
      );
    },
    [values]
  );

  return (
    <>
      {values.map((value, i) => {
        return (
          <NumberInput
            key={i}
            index={i}
            value={value}
            onChange={onChange}
            onCheck={onCheck}
          />
        );
      })}
      <p>合計は{sum(values)}</p>
      <p>{message}</p>
    </>
  );
}

これで問題は解決できたように見えるが、解決できていない。

useCallbackを利用することで、onChangeは同じ関数オブジェクトを渡すようになったが、以下のonCheckが問題。

const onCheck = useCallback(
  index => {
    const total = sum(values);
    const ratio = Number(values[index]) / total;
    setMessage(`${values[index]}は${total}の${(ratio * 100).toFixed(1)}%です`);
  },
  [values]
);

onCheckuseCallbackの第2引数が[values]となっているため、valuesが変わるたびにonCheckが再生成される。

onCheckが再生成されると、InputNumberが再レンダリングされるので、React.memoが意味をなしてない(再レンダリングを防げてない)。

そのため、onCheckvaluesに依存している状況をどうにかする必要がある。

一方で、onChangevaluesを利用しているが、valuesに依存していない。

const onChange = useCallback((index, value) => {
  setValues(values => {
    const newValues = [...values];
    newValues[index] = value;
    return newValues;
  });
}, []);

これは、useStateが提供する state 更新関数が、関数による state の更新をサポートしているから。

上記のコードでは、setValues関数の引数として「現在の state(values) を受け取って、新しい state を返す関数」を渡している。

そのため、onChangevaluesに依存していない。

つまり、onCheckmessageという state を更新するにあたって、それとは別のvaluesという state に依存していることが問題。

これを解消するためには、2つの state を合体させて2つの state にする必要がある。

このような状況に適しているのがuseReducer

useReducerによる解決

前述のコードをuseReducerを利用して書き換えると以下のようになる。

import React, { useReducer } from "react";

const sum = arr => arr.reduce((acc, cur) => acc + (Number(cur) || 0), 0);

const reducer = (state, action) => {
  switch (action.type) {
    case "input": {
      const newValues = [...state.values];
      newValues[action.index] = action.value;
      return {
        ...state,
        values: newValues
      };
    }
    case "check": {
      const total = sum(state.values);
      const ratio = Number(state.values[action.index]) / total;
      return {
        ...state,
        message: `${state.values[action.index]}は${total}の${(
          ratio * 100
        ).toFixed(1)}%です`
      };
    }
    default: {
      return state;
    }
  }
};

const NumberInput = React.memo(({ value, index, dispatch }) => {
  console.log("render NumberInput");
  return (
    <p>
      <input
        type="number"
        value={value}
        onChange={e =>
          dispatch({
            type: "input",
            index,
            value: e.currentTarget.value
          })
        }
      />
      <button
        onClick={() =>
          dispatch({
            type: "check",
            index
          })
        }
      >
        check
      </button>
    </p>
  );
});

export default function App() {
  const [{ values, message }, dispatch] = useReducer(reducer, {
    values: ["0", "0", "0", "0"],
    message: ""
  });

  return (
    <>
      {values.map((value, i) => {
        return (
          <NumberInput key={i} index={i} value={value} dispatch={dispatch} />
        );
      })}
      <p>合計は{sum(values)}</p>
      <p>{message}</p>
    </>
  );
}

前述のonChangeonCheckが担っていたロジックはreducer内のロジックに置き換わった(reducerが担うことになった)。

そのため、NumberInputにはdispatchを渡すだけになった。

また、useReducerを利用することで、2つの state が1つの reducer に集約された。

つまり、「valuesを見てmessageを決める」という計算が、「今の state から次の state を計算する」という枠組み(reducer)の中に入ったということになる。

結果として、dispatchは state がどのような変更をされるのかは知らなくて済むようになった(アクションを送るだけで良くなった)。

dispatchは state に非依存の関数なので、前述のonCheckのようにvaluesが更新されても関数は再生成されなくなった。

そして、再生成されないdispatchを渡すことにより、React.memoによる再レンダリングを防ぐことができるようになった。

ポイントの整理(今回行ったことを振り返る)

今回の最も重要だったことは「state の更新関数を state に非依存にする」ことである。

useState で state の更新関数を state に非依存にするためには

前述のuseStateを利用したコードでは、onCheckという関数が state(values)に依存している関数だったので、再レンダリングを防げなかった。

state の更新関数を state に非依存にするには、「現在の state を受け取って次の state を計算させる」必要がある。

useStateでこれを実現するには、state 更新関数に関数を渡す必要がある。

そのため、、以下のようにするのではなく、

setValues(newValues);

以下のようにする必要がある。

setValues(currentValues => {...; return newValues });

前述のonChangeではこれができていたが、onCheckではできなかったので、再レンダリングを防げなかった。

useReducer で何をしたのか

これを改善するために、useReducerを利用して「2 つの state(valuesmessage)を 1 つに合体させる」ことを行った。

結果として、onCheckでも、state に依存しない関数(dispatch関数)による state 更新ができるようになった。

頑張ればuseStateでも同じようなことはできるが、このような複雑な state を扱うにはuseReducerが適しているので、useReducerを選択することになる。

ということで、useReducerを利用すれば、state 更新関数(dispatch)は自動的に state に非依存になる。

useReducerのすすめ

このように、useReducerを用いることで、state 更新関数を state 非依存にすることを強制できる

実際のアプリ開発においては、以下のような状況になる可能性がある。

  • アプリが複雑化するにつれて、ある state と別の state が関わりを持ち始める。
  • ある state を更新するときに別の state を見る必要が発生する。

このような状況になったら、useReducer導入のサインなので、リファクタリングしてuseReducerを導入する。

なぜuseReducerが必要なのか

  • useReducerを利用することで、state 更新関数が state に非依存になるから
  • state 更新関数が state に非依存になることで、React.memoを活用しやすくなるから

useReducerReact.memoの恩恵を最大限受けるためには

useReducerReact.memoの恩恵を最大限受けるためにはできるだけreducerにロジックを詰め込むことが重要。

reducerにロジックを詰め込むには、アプリの状態は何でも state で表現する必要がある。

そのため、state は明示的・宣言的に扱う(手続き的なロジックは書かない)ことが重要である。

また、useReducerを活かすためには、そのためのコンポーネント設計も重要。

今回、useReducerを活かすために、NumberInputindexを props で受け取るようにした

なぜこのようにしたのかと言うと、dispatchを呼び出して自分のvalueを更新するためには、自分が何番目かをdispatchに教える必要があるから。

このように、useReducerを活かすためにはコンポーネント設計も重要になる。

副作用はどうするのか?

今回の例では、useReducerによって state をひとつにまとめることで、onCheckコールバックを state に非依存にすることができた。

もし、「check」ボタンを押すと起こることが、何らかの副作用(HTTP リクエストが発生するなど)だったらどうするのか。

現時点では、副作用は reducer の中に書くべきではないという原則があるので、この記事で使った手を使うことはできないし、現時点では対処法はない

副作用をどこかのコールバック関数に書いた時点で、その関数が state に依存することとなり、React.memoによるパフォーマンス改善の妨げとなる。

Redux の本質

上記の問題を解消する手段の1つが Redux である。

Redux を用いた state 管理の場合、Redux ミドルウェアの活用によって、state に依存する副作用ですらdispatchの中に押し込めることができる。

つまり、副作用ですら state に非依存させることができる。

Redux の本質は React のツリーの外で state を管理してくれることであり、それによりReact 本体のみでは困難な state 非依存性が実現している。

Redux はただ state 管理に関する統一的な方法論を与えるだけでなく、このようなパフォーマンス上のメリットもあるということは覚えておいて損はない。

React 17.x 系の展望

しかし、React 17.x 系(いわゆるConcurrent Modeが導入されると期待されています)ではまた情勢が変わる可能性がある。

端的に言えば、Concurrent Mode においては(主に非同期的な)副作用ですら state 内で管理されるようになり、そのための道具が Suspense である。

Concurrent Mode では副作用と state 管理の概念が大きく様変わりし、Redux などに頼らずともパフォーマンス的に最適な副作用の扱いが達成できる場面が増えると予期される。

まとめ

この記事では、コールバック関数が state に依存する場合に、React.memoの恩恵を受けられないという問題に対してuseReducerを用いて対処する方法を示した。

ポイントはstate 更新関数を state 非依存にすることであり、(useStateでもそれは可能なものの)useReducerはそのような書き方に適している。

記事冒頭のまとめを再掲。

  • useReducerは、state に依存するロジックを、state に非依存な関数オブジェクト(dispatch)で表現できる。
  • state に依存するロジックを、state に非依存な関数オブジェクトで表現できることにより、React.memoによるパフォーマンス改善を行える。
  • useReducerを活かすには、state を1つにまとめることで、ロジックをなるべく reducer に詰め込む。

useReducerの存在価値は単に Redux と同じ書き方ができるというだけではなく、この記事で説明したような本質的な問題を解決するために利用できる。