io-tsでJSONの型検査をする

2 min read読了の目安(約2600字

TypeScript内でJSONを使うことは多いが悩みの一つがJSON.parse()の戻り値はanyになって型が不明になることである。

const x: number =  JSON.parse(json);

とすると型はつくにはつくが特に検査するわけではないので

const x: number =  JSON.parse('"abc"');
const y = x.a * 2 // => NaN になる

とやってランタイムで滅茶苦茶になってしまうのは防げない。

io-ts (https://github.com/gcanti/io-ts) を使うとJSONの型検査ができる。しかしこれはTypeScriptで関数型プログラミングをするライブラリのfp-ts(https://github.com/gcanti/fp-ts) をベースにしているのでこちらの知識が必要になる。

ライブラリの中にはnumberやstringの検査するものと組み合わせてobjectを作ったりするものがある。

以下Jestのテストコードでの使用例。

import * as t from "io-ts";
import { isRight, map, right } from "fp-ts/lib/Either";
import { pipe } from "fp-ts/lib/pipeable";

describe("JSON.parse()のみ", () => {
  test("stringをobjectと処理するとundefined", () => {
    const x = JSON.parse(`"a"`);
    expect(x.b).toBeUndefined();
  });
  test("存在しない属性はundefined", () => {
    const x = JSON.parse(`{"a":123}`);
    expect(x.b).toBeUndefined();
  });
  test("stringをnumberとして処理するとNaN", () => {
    const x: number = JSON.parse(`"a"`);
    expect(x * 2).toBeNaN();
  });
});

describe("io-tsを使う", () => {
  test("stringはnumberではない", () => {
    const x = t.number.decode(JSON.parse(`"a"`));
    expect(isRight(x)).toBeFalsy();
  });
  test("numberはstringではない", () => {
    const x = t.string.decode(JSON.parse(`123`));
    expect(isRight(x)).toBeFalsy();
  });

  test("オブジェクトの検査", () => {
    const X = t.type({ a: t.number });
    const x = X.decode(JSON.parse(`{"a":123}`));
    expect(x).toStrictEqual(right({ a: 123 }));
  });
  test("オブジェクトのプロパティが一致しない", () => {
    const X = t.type({ x: t.number });
    const x = X.decode(JSON.parse(`{"a":123}`));
    expect(isRight(x)).toBeFalsy();
  });
  test("余分なプロパティがあっても良い", () => {
    const X = t.type({ a: t.number });
    const x = X.decode(JSON.parse(`{"a":123,"b":456}`));
    expect(x).toStrictEqual(right({ a: 123, b: 456 }));
  });
  test("exactを使うと余分なプロパティは消える", () => {
    const X = t.exact(t.type({ a: t.number }));
    const x = X.decode(JSON.parse(`{"a":123,"b":456}`));
    expect(x).toStrictEqual(right({ a: 123 }));
  });
  test("unionの例", () => {
    const X = t.union([
      t.type({ x: t.literal("number"), a: t.number }),
      t.type({ x: t.literal("string"), a: t.string })
    ]);
    type XType = t.TypeOf<typeof X>; // 型情報が取れる
    const num = X.decode(JSON.parse(`{"x": "number", "a":123}`));
    const str = X.decode(JSON.parse(`{"x": "string", "a":"xyz"}`));

    const f = (a: XType) => {
      switch (a.x) {
        case "number":
          return a.a * 2;
        case "string":
          return a.a.length;
      }
    };
    expect(pipe(num, map(f))).toStrictEqual(right(246));
    expect(pipe(str, map(f))).toStrictEqual(right(3));
  });
});