9
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

TypeScript + Tynderから始める宣言的検証生活

Last updated at Posted at 2020-02-08

皆さんは JSON Schema 使ってますか?
現在では、Web APIのペイロード定義・検証、モックサーバー作成ユーザー入力フォーム検証設定ファイルのスキーマ定義・検証・IDEでのエラー表示など、多くの場面で、また多くの言語でライブラリが整備され利用されています。

JSON Schemaの強み

  • Internet draftのフォーマットで仕様が公開されている
  • 多くの言語での多くの実装(言語によっては複数)が存在する
    • 1回書けば、フロントエンド、複数のバックエンドすべてで利用できる可能性が高い
    • 代替実装が存在すると競争原理が働く

JSON Schemaの嫌いなところ

  • 見辛い
  • 書き辛い

数行の小さなスキーマならばともかく、JSON Schemaって本当に苦痛。汎用のデータフォーマットを人が直接記述するレイヤーのDSLにするのは正直辛い。ヒューマンリーダブルだからといって人が読めるとは限らないのだよ…(古のXML Hellを彷彿する)

JSON Schema 書きたくない😑…
そうだ、新しいスキーマ検証ライブラリを作ろう😎

準備

本題のスキーマ検証に進む前に、少し型安全性(type safety)についておさらいします。
スキーマ検証によって最終目的であるデータの妥当性検査を行うだけでなく、途中のコードに型安全性を与えることも重要と考えているからです。

型安全性って何?

大変大雑把に言えば、型を間違えたせいでバグらない、ということです。
例えば、加算したつもりが文字列連結になったり、(実は存在しなかった)フィールドの値を文字列結合したらundefinedという文字列が結合されたりするバグが起こせるなら、型安全ではありません。

ちなみに、どんなフィールドを持っているか分からない型のオブジェクト(C#のdynamic, JavaScript等の多くのスクリプト言語でのすべての変数の型)において、(実は存在しなかった)メソッドを呼んだら例外が発生した、という動作は型安全性があると考えます。

実は「型安全性」の定義は基づいている「型システム」(の視点)により変化するものです。
なお、言語が「型安全性」をどれだけ持っているかという「強い型付け」・「弱い型付け」というワードは割と曖昧です。

TypeScriptと静的型安全性

TypeScriptという言語は、JavaScriptの文法を拡張し、JavaScriptに対する型アノテーションを記述できるようにします。TypeScriptコンパイラ(tsc)は、(ES6文法の変換等も行いますし、一部構文のために追加のコード生成を行ったりもしますが)大まかに言えば、型検査(type checking)を行い、元のコードから型アノテーションを削除したコードを出力します。

コンパイラオプションにも依りますが、コンパイル時の型検査によって、幾らかの静的型安全性を提供します。

JavaScriptと動的型安全性

コンパイルされたTypeScriptのコードは、紛れもなくJavaScriptのコードであり、JavaScriptのランタイムにより実行されます。JavaScriptランタイムは、変数・引数・戻り値の型に関心がありません。ランタイムは型検査を行わない(動的型安全性無い)ため、実行時に変数・引数・戻り値が想定した型を持っているか検査するのはプログラマーの責任になります。

  • JavaScriptはそもそも型を明示する文法を持っていないので、例えランタイムが型検査の機能を持っていたとしても、ロード時(静的)・実行時(動的)ともに検査のしようがありません。

静的と動的

ランタイムが型検査を行わないJavaScriptにおいて、プログラマーは一切型を検査しないコードを書くこともできますし、厳密に検査することもできます。検査をしなければコードを小さく、簡略に、また高速にできます。しかし、意図しない型を受け入れれば、プログラムはおそらく意図しない動作をする(=「ある種の(certain)」バグがある)でしょう。ここに 性能および開発リソース と 安全 との間のトレードオフがあります。

  • 型検査をパスすれば「ある種の」バグが無いと言えますが、型検査をパスできなくても「ある種の」バグがない場合(false possible; 偽陽性)が有り得ます。
  • 「ある種の」バグと限定するのは、型検査で発見されるバグとは型の違いによって引き起こされる様々な動作のことであり、型検査ですべてのバグが発見されるわけでは無いためです。
  • そもそも任意のプログラムの「すべてのバグ」を発見することは、チューリングマシンの停止性問題であり、実現できません。

信頼できる呼び出し元から渡されたデータ、信頼できる関数から返されるデータのは(実行せずとも)信頼できる。そう考え、プログラムの実行前(例えばコンパイル時)にそれぞれの期待する型に矛盾がないか検査し、検査が通った場合のみ実行できるようにするのが静的型安全性があるということです。静的型安全性があれば、実行時の検査を行わなくても意図した型を受け入れていることが保証されます。

  • 信頼できるのは「型」だけであって、「値」が信頼できるわけではありません。
  • 数学的に証明できますが、私には書けそうにありませんここに記すには余白が狭すぎるようですね。

実行時に検査して、意図する型と矛盾がある状態のままプログラムを進行させないようにする(例えば例外をスローする)のが動的型安全性です。

型破りな憎いあいつ(any)

それでは、静的型安全性が壊れるのはどんなときでしょうか? TypeScriptの場合で考えると以下のようなケースが考えられます。

  • (外部ライブラリの型定義ファイルが誤っていてコンパイルが通らないので)型をanyで握りつぶした、あるいは、anyを経由して任意の型にキャストした
    • 自分が予想した型と実行時の型が異なるかもしれない
  • 外部ライブラリの型定義ファイルが誤っているがそれに気付かず利用した
  • (外部ライブラリや自分の作成した関数の戻り値の型をを他の関数の引数に利用したいが、計算される型が複雑過ぎて記述できないので)anyまたは正確ではない型を指定した
  • (外部ライブラリや自分の作成した関数の戻り値の型ををFluent APIの次の呼び出しに利用したいが、計算される型がお気持ちに反したので)anyまたはお気持ちに合った型を指定した
    • 例えばconst z = [['', 0]].map(x => x);Array<[string,number]>ではなく (string|number)[][] となってしまう
      • Array.from(map.entries()).map(x => x)で詰む
  • 信頼できないユーザー入力や外部システムからのリクエストをとりあえずanyを経由して任意の型にキャストした
    • いや、unknown型のconstに束縛しましょう
      • const unknownInput: unknown = {...};

他にも怠惰を目的に、あるいは、上手く型推論してくれないため止むを得ず、型システムを欺瞞する際に私達は静的型安全性を破壊します。

今回は、スキーマ検証がテーマなので、静的型安全性を破壊した上で動的型安全性の無いランタイムでプログラムを実行する勇気については多くを語りません(~~最悪、致命傷となるだけです。安心しましょう。~~しっかりテストしましょう)。

スキーマを使って入力検証をしよう

入力検証には幾つかの段階があります(要出典)。

  1. 型の検査
  2. 単独の項目の必須・値の長さ・範囲や文字列パターンの検証
  3. 複数項目の相関や整合性の検証
  4. 永続化データやデータモデルとの整合性の検証(存在チェック、重複チェック、権限チェック、意味的なチェック、等)

もし、あなたが動的型安全性のある言語・ランタイムを使用しており、モダンなフレームワークを用いているならば恐らく、ユーザーのフォーム入力やリクエストのペイロードは、あなたの定義した型にマッピングされて渡され、ライブラリまたはランタイム自身によってマッピング先の型とデータの型が比較され、検査をパスする場合のみ正常処理が可能となるでしょう。

動的型安全の世界では、少なくとも「1. 型の検査」について悩む必要がありません。
残念ながらTypeScriptの言語機能・JavaScriptランタイムの機能では、実行時に渡される怪しいデータを型安全の世界に引き戻すことはできません。
型の検査と値の検証を手続的に毎回書くのは非常に手間が掛かるだけでなく、ミスや修正漏れ等によってバグを生む温床となります。

そこで、今回は Tynder というライブラリを使用します。(私がつくりました)

Tynder とは

tynder-logo.png

Tynder は、TypeScriptのサブセット+独自の拡張文法から成るDSLによって

  1. 型の検査
  2. 単独の項目の必須・値の長さ・範囲や文字列パターンの検証
  3. 複数項目の相関や整合性検証の一部 (Union typeによる)

宣言的に行うことができます。

さらに、TynderはDSLからTypeScriptの型定義を生成するので、定義した型をTypeScriptコンパイラによる静的型検査に使用できます。
検証は、Tynder自身の持つバリデーターで行うことができるほか、JSON Schemaを生成することで、他の言語で作成されたサブシステムともスキーマを共有できます。
表現力が高く可読性の高いTypeScriptで一度スキーマを記述すれば、どこでも使えます。

write-once.png

スキーマの例
/// @tynder-external RegExp, Date, Map, Set

/** doc comment */
export type Foo = string | number;

type Boo = @range(-1, 1) number;

/** doc comment */
interface Bar {
    /** doc comment */
    a?: string;                                                   // Optional field
    /** doc comment */
    b: Foo[] | null;                                              // Union type
    c: string[3..5];                                              // Repeated type (with quantity)
    d: (number | string)[..10];                                   // Complex repeated type (with quantity)
    e: Array<number | string, 4..>;                               // Complex repeated type (with quantity)
    f: Array<Array<Foo | string>>;                                // Complex repeated type (nested)
    g: [string, number],                                          // Sequence type
    h: ['zzz', ...<string | 999, 3..5>, number],                  // Sequence type (with quantity)
}

interface Baz {
    i: {x: number, y: number, z: 'zzz'} | number;                 // Union type
    j: {x: number} & ({y: number} & {z: number});                 // Intersection type
    k: ({x: number, y: number, z: 'zzz'} - {z: 'zzz'}) | number;  // Subtraction type
}

/** doc comment */
@msgId('M1111')                                                   // Custom error message id
export interface FooBar extends Bar, Baz {
    /** doc comment */
    @range(-10, 10)
    l: number;                                                    // Ranged value (number)
    @minValue(-10) @maxValue(10)
    m: number;                                                    // Ranged value
    n: @range(-10, 10) number[];                                  // Array of ranged value
    @greaterThan(-10) @lessThan(10)
    o: number;                                                    // Ranged value
    p: integer;                                                   // Integer value
    @range('AAA', 'FFF')
    q: string;                                                    // Ranged value (string)
    @match(/^.+$/)
    r: string;                                                    // Pattern matched value
    s: Foo;                                                       // Refer a defined type
    @msgId('M1234')
    t: number;                                                    // Custom error message id
    @msg({
        required: '"%{name}" of "%{parentType}" is required.',
        typeUnmatched: '"%{name}" of "%{parentType}" should be "%{expectedType}".',
    })
    u: number;                                                    // Custom error message
    @msg('"%{name}" of "%{parentType}" is not valid.')
    v: number;                                                    // Custom error message
}

// line comment
/* block comment */

概ね、見慣れたTypeScriptの構文ですね。
拡張文法のデコレーター(@range()等)によって型が修飾され、値の範囲やパターンが指定されています。
また、配列には量指定子(3..5等)を付加することで、長さを指定しています。

検証

コンパイラを含めるとややフットプリントが大きくなる(約135KB; 2020年2月現在)ので、DSLはプリコンパイルしておくのが望ましいのですが、次のサンプルでは実行時にコンパイルします。

  • コンパイラを除外した時のサイズは約37KBです。
myschema.ts
import { compile } from 'tynder/modules/compiler';

export default const mySchema = compile(`
    type Foo = string;
    interface A {
        @maxLength(4)
        a: Foo;
        z?: boolean;
    }
`);
validate.ts
import { validate,
         getType }           from 'tynder/modules/validator';
import { ValidationContext } from 'tynder/modules/types';
import default as mySchema   from './myschema';

const validated1 = validate({
    a: 'x',
    b: 3,
}, getType(mySchema, 'A')); // {value: {a: 'x', b: 3}}

検証が成功すると入力を含むオブジェクトが返り、失敗するとnullが返ります。

チェリーピックとパッチ

データモデルの一部のみを抜粋して編集させ、検証した後、元のデータモデルにマージしたいこともあります。そのために pick()patch() 関数が用意されています。

import { getType }           from 'tynder/modules/validator';
import { pick,
         patch }             from 'tynder/modules/picker';
import { ValidationContext } from 'tynder/modules/types';
import * as op               from 'tynder/modules/operators';
import default as mySchema   from './myschema';

const original = {
    a: 'x',
    z: false,
};
const needleType = op.picked(getType(mySchema, 'A'), 'a');

try {
    const needle = pick(original, needleType); // {a: 'x'}
    const unknownInput: unknown = { // Edit the needle data
        ...needle,
        a: 'y',
        q: 1234,
    };
    const changed = patch(original, unknownInput, needleType); // {a: 'y', z: false}
} catch (e) {
    console.log(e.message);
    console.log(e.ctx?.errors);
}

検証が成功するとマージされたオブジェクトが返り、失敗すると例外がスローされます。

そして型安全へ

予めDSLをコンパイルして、スキーマファイルとTypeScriptの型定義ファイルを生成しておきます。

tynder compile --indir path/to/schema --outdir path/to/schema-compiled
tynder gen-ts  --indir path/to/schema --outdir path/to/schema-types

型定義とスキーマをimportします。
検証前の入力データはunknown型、検証後のデータはimportした型のconstに束縛します。

import { deserializeFromObject } from 'tynder/modules/lib/serializer';
import { Foo, A }                from './path/to/schema-types/my-schema';         // type definitions (.d.ts)
import mySchemaJson              from './path/to/schema-compiled/my-schema.json'; // pre-compiled schema

// シリアライズされたスキーマを復元します
const mySchema = deserializeFromObject(mySchemaJson);

const unknownInput: unknown = {a: 'x'};
const validated = validate<A>(unknownInput, getType(mySchema, 'A'));

if (validated) {
    const validatedInput = validated.value; // validatedInputは 型「A」になります
    ...
}

このように記述することで、検証後のデータは型安全となります。
先ほどの pick, patchの例も同様に型安全に留意して書き直してみましょう。


import { pick,
         patch }                 from 'tynder/modules/picker';
import { ValidationContext }     from 'tynder/modules/types';
import * as op                   from 'tynder/modules/operators';
import { deserializeFromObject } from 'tynder/modules/lib/serializer';
import { Foo, A }                from './path/to/schema-types/my-schema';         // type definitions (.d.ts)
import mySchemaJson              from './path/to/schema-compiled/my-schema.json'; // pre-compiled schema

// シリアライズされたスキーマを復元します
const mySchema = deserializeFromObject(mySchemaJson);

interface Store {
    baz: A;
}
const store: Store = {
    baz: {
        a: 'x',
        z: false,
    }
};

const needleType = op.picked(getType(mySchema, 'A'), 'a');

try {
    const needle = pick(store.baz, needleType); // {a: 'x'}
                                                // `needle` is RecursivePartial<A>
    const unknownInput: unknown = {             // Edit the needle data
        ...needle,
        a: 'y',
        q: 1234,
    };
    store.baz = patch(store.baz, unknownInput, needleType); // {a: 'y', z: false}
} catch (e) {
    console.log(e.message);
    console.log(e.ctx?.errors);
}

さいごに

TypeScript + Tynder で、宣言的な検証を始めてみませんか?

参考文献

9
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?