🐟

TypeScriptの型と値とバリデーション

2024/06/11に公開

TypeScript は本質的に自分に型が付与されていると思っているだけの JavaScript です。
いくら型を付与しようが、それが実行時に影響を与えることはありません。

コードレビューをしているとここを誤解している人が本当に多いです。何度も解説しているのですが、なかなか浸透しないので、TypeScript におけるバリデーションという視点で記事を書くことにしました。

あと TS でバリデータ使って色々作ろうとしている友人と、プログラミング始めたてで zod と openapi を使っいる友人がいたので、彼らが想定読者です。

型と値の名前空間

TypeScript 上での名前空間(スコープ)は2つに分類できます。

  • 値: 実行時にランタイム上のメモリに存在するもの
  • 型: 静的解析時にのみ参照可能なもの。コンパイル時に完全に消滅する。

TypeScript は基本的に JavaScript の上位互換の文法を持ち、 TypeScript から JavaScript への変換とは、 const x: number = 1;: number のような型アノテーションを消去する処理を指します。

実行時には型を消去するという特性上 TypeScript の型アノテーションは、値として一切存在しません。

例えば Scala の Manifest 型や、 ランタイムの一部としてDSLで型を定義する Ruby の sorbet では型が値として存在しますが、TypeScript でこのようなことは発生しません。

型の名前空間のみに影響する構文として具体的には import type, type, interface, implements, as, satisfies 等があります。

import type { Foo } from "./foo";
type Bar = { bar: number };
interface Baz extends Bar { baz: string }

// @ts-expect-error
console.log(Foo, Bar, Bar);

class X implements Baz {
  bar = 1;
  baz = 'x';
}

逆に、一部を除いて、値はそのままだと型として使用できません。使いたい場合は型の名前空間で typeof を使う必要があります。

const foo: number = 1;
type Foo = typeof foo;

const add = (a: number, b: number): number => 1;
type AddType = typeof add; //-> (a: number, b: number) => number;

この型名前空間で使える typeof は、ランタイムの typeof とは別物であることに注意してください。

const t = 1;
console.log(typeof t); //=> "number"
type T = typeof t; //=> TypeScript の型としての number

この値の typeof は TypeScript の型情報を復元できるほどの表現力はありません。number, string, boolean のようなプリミティブ型と object 型であるか否かぐらいしかわかりません。

TypeScript の特異な点は、動的型付け言語に変換される、静的型付けの言語であるということです。よく初心者が誤解していますが、動的型付け言語は型が存在しないわけではなく、実行時に型が付与される言語です。 typeof はランタイム時の型を取り出す言語機能です。

型スコープは値スコープと同じく構文的なクロージャを持ちます。

type X = number;
{
  type Y = number;
  {
    type Z = number;
  }
  // @ts-expect-error
  const v: Z = 1;
}
const v: X = 1;

型と値を同時に生成する class

TypeScript は型と値が区別されると言いましたが、 class は少し特別な振る舞いをします。

class Point {
  constructor(public x: number, public y: number) {}
}

const p1: Point = new Point(1, 2);

class 構文は Point というコンストラクタとしての値と、 Point というインスタンスの型を同時に定義します。

なので、次のコードも動くには動くんですが、ランタイム上でやや異なる挙動をします。

const p1: Point = new Point(1, 2);
const p2: Point = {x: 1, y: 2};

console.log(p1 instanceof Point, p2 instanceof Point); //=> true, false

instanceof も JavaScript の組み込みの機能で、値が class から new された値であれば(厳密に言うなら、同一 prototype を持つなら) true に、そうでないなら false になります。

p2 instanceof Point が false になるのは、 Point の型としてのインターフェースは満たしているけども、値が作られる過程が異なっているからです。

Point 型は Point の値の型ではない点に注意してください。次のコードは失敗します。

// @ts-ignore
class Point2: Point = Point;

これを理解するために、次のコードを見てみましょう。

class X {
  constructor(private v: number) {}
}

const XConstructor: typeof X = X;
type XInstance = InstanceType<typeof X>;

const x: XInstance = new XConstructor(1);

これは型チェックが通るコードです。 X からあえてコンストラクタとしての型と、インスタンスの型を取り出して、それを使ってインスタンスを作るコードです。

また、 class 構文を使わずに new されるオブジェクト型を定義することも可能です。

class X {
  constructor(private v: number) {}
}

declare const X2: {
  new(v: number): X;
};
const x2: X = new X2(1);

declare は値の名前空間にそのシンボルが存在することを宣言する機能です。これは主にグローバル変数としてアクセス可能なシンボルが定義されている時に使う機能で、実際にランタイムに存在するかは、プログラマが別に保証しなければなりません。(積極的に使いたい機能ではないです)

逆に、 継承しないと使えない class を宣言する abstract class は、その名前に反して必ずコンストラクタの値として存在します。 TypeScript が直接 new できないという制約を掛けているだけで、ランタイムとしては new 可能です。

abstract class X {}
// @ts-ignore
const x = new X();

クラスと値と型の関係は、とくに Java/C# から来た人が型キャストの振る舞いを誤解していることがあります。 とくに ReactNative のモバイルアプリのコードで、この辺の理解が怪しいコードを見かけます。

enum

enum も class と同じように 型と値を同時に定義する文法です。

enum E {
  A,
  B,
  C
}
const e: E = E.A;

これは値として次のように展開されます。

var E;
(function (E) {
    E[E["A"] = 0] = "A";
    E[E["B"] = 1] = "B";
    E[E["C"] = 2] = "C";
})(E || (E = {}));

enum は JavaScript の文法として存在しない TypeScript 特有の機能で、リリース初期から存在していました。

これによって TS は JS の型以外の文法を拡張しないという暗黙のルールを破っているのですが、便利なので使われ続けています。今更消せないですしね。。。

ただし、生成するコードの量が多く、またバンドラや minifier と相性が悪いので、ビルドサイズに配慮する場合は避けたほうがいいでしょう。

これはユニオン型を使って次のように書き換えることができます。

const A = 0;
const B = 1;
const C = 2;
type E = typeof A | typeof B | typeof C;

const v: E = A;

または、コンパイル時に消去される const enum を使うことができます。自分はこっちが好みです。

const enum E {
  A,
  B,
  C
}
const e: E = E.A;
// const e = 0 /* E.A */;

ただし、 const enum のときは値からキーを取り出す機能が使用できなくなります。

E[E.A] //=> "A"

これが有用なのは主にデバッグ時なので、自分は普段は const enum を使ってデバッグ時にのみ const を外す、という運用をしています。

型はランタイムへ持ち込むことができない

TypeScript は JavaScript に変換されてから実行される言語なので、JSへの変換時にTypeScript としての静的な型情報が全て失われます。

また、(特殊なコンパイラプラグインを使わない限り)実行時にシンボルにどのような型情報を与えられているか、知る方法はありません。

極論、 TypeScript は自分に型があると思いこんでいるだけの JavaScript でしかなく、実際ランタイムではいくらでも TypeScript の推論状態に違反するコードを書くことが出来ます。怖いですね。

これに対するユーザーレベルでの対応策は、 TypeScript の推論状態と一致するようなお行儀がいいコードを書く、ということしかありません。 any を避ける、 @ts-ignore を避ける、as のアップキャストを避ける、といった話になります。

値バリデーションと型推論

先に述べたように、TypeScript は自分に型があると思いこんでいる JavaScript です。つまり、型を付与しただけでは実際の値を検証することができていません。

自分が書いたコードの範囲内で値のバリデーションをしないのは自己責任の範囲なのですが、問題なのはシステム境界から渡されるデータの検証です。例えばサーバーにおける Request のペイロードです。

type User = {
  name: string;
  age: number;
};

export function onFetch(req: Request) {
  // any を User にアップキャストしているが
  // これは本当に User 型を満たしているか?
  const user: User = await req.body();
  ...
}

値から型を推論することができるので、ランタイムでスキーマを組み立ててその推論として型を取り出すことは可能です。代表的なのが zod です。

初学者向けの補足: クライアント・サーバーをどちらも自分で開発している場合、ここで型に違反するようなリクエストが存在するか疑問に思うかもしれません。ここで、ブラウザの Devtools(F12) を実際は誰でも使えるということを思い出してください。 Devtools で fetch(...) を叩くと、サービスの利用者からはどんなリクエストでも作れてしまいます。実際に悪意があってサービスを破壊する人は、ここで任意のデータを作って送信してくるということを念頭においてください。今回はDevtools で説明しましたが、任意の HTTP Client でも同じことが可能です。

zod

import {z} from "zod";

const schema = z.object({
  name: z.string(),
  age: z.number(),
});

type User = z.infer<typeof schema>;

const parsed = schema.safeParse({ name: "John", age: "25" });
if (parsed.success) {
  const user: User = parsed.data;
}

z.infer<typeof schema> でスキーマ定義に対応した型を取り出すことが出来ます。
これをバリデーションに使いつつ、パースされたものは型を満たす、という運用をすることが可能です。

jsonschema

jsonschema は typescript とは直接関係なく、 json に対する制約を表現するスキーマ定義です。

仕様があって、それに対するバリデータの実装があり、今一番使われているのは(多分) ajv です。

https://ajv.js.org/

ajv を使って、型に対応する jsonschema を書かせるパターン。

import { JSONSchemaType, Ajv } from "ajv";
const ajv = new Ajv();

type User = {
  name: string;
  age: number;
}

const schema: JSONSchemaType<User> = {
  type: "object",
  required: ["name", "age"],
  properties: {
    name: { type: "string" },
    age: { type: "number" },
  },
  additionalProperties: false,
};
const validate = ajv.compile(schema);
const user: unknown = { name: "John", age: "25" };
if (validate(user)) {
  const u: User = user;
}

これでもいいのですが、型に対応する json を自分で書かないといけないのは、多少面倒です。正直自明なはずなんですが型から値を取り出せない以上、型から値を展開できない TypeScript だけで完結するにはこう書かざるを得ません。

他のアプローチとして、事前にに存在する jsonschema から型定義を生成する方法があります。例えば json-schema-to-typescript です。

https://www.npmjs.com/package/json-schema-to-typescript

$ npx json-schema-to-typescript -i schema.json
export interface Schema {
  name: string;
  age: number;
  [k: string]: unknown;
}

これも悪くなさそうですが jsonschema を書き換えたらこの CLI で型定義を生成しなおす、というワークフローが挟まるのがちょっと鬱陶しいですね。

逆に、 jsonschema から TypeScript の型を推論するコードを自分で書くのはそう難しくありません。

export type JSONSchema = {
  type: 'string' | 'number' | 'boolean' | 'object' | 'array';
  description?: string;
  properties?: {
    [key: string]: JSONSchema;
  };
  items?: JSONSchema;
  required?: readonly string[];
};

type ToType<T> = T extends { type: 'string' }
  ? string
  : T extends { type: 'number' }
  ? number
  : T extends { type: 'boolean' }
  ? boolean
  : T extends { type: 'array'; items: infer U }
  ? ToType<U>[]
  : T extends { type: 'object'; properties: infer P; required: infer R extends readonly string[] }
  ? { [K in keyof P]-?: K extends R[number] ? ToType<P[K]> : ToType<P[K]> | undefined }
  : never;

export type SchemaToType<T extends JSONSchema> = ToType<T>;

// use

const schema = {
  type: 'object',
  required: ['name', 'age'],
  properties: {
    name: {
      type: 'string'
    },
    age: {
      type: 'number'
    }
  }
} satisfies JSONSchema;

type User = SchemaToType<typeof schema>;

const user: User = {
  name: 'john',
  age: 25
}

TypeScript の実装から jsonschema を生成ツールもあるにはあるんですが、自分が試した感じ、どれもちょっと微妙です。

TypeScript の意味解析が伴うので、実装が難しいんですよね。。。

どれも一長一短です。

openapi

URL エンドポイントとそのパスがハンドルする jsonschema を記述する仕様が openapi(swagger) です。

openapi: 3.0.0
info:
  version: 1.0.0
  title: Sample API
  description: A sample API to illustrate OpenAPI concepts
paths:
  /list:
    get:
      description: Returns a list of stuff              
      responses:
        '200':
          description: Successful response

これから TypeScript の型定義を生成して、 HTTP Client の型定義を作ることが出来ます。

$ npx openapi-typescript spec.yaml -o spec.d.ts

これを使って型定義されたHTTP クライアントを作ります。

import type { paths } from "./spec.d.ts";
import { Fetcher } from 'openapi-typescript-fetch';

const fetcher = Fetcher.for<paths>()
fetcher.configure({
  baseUrl: 'http://localhost:8000',
});

const list = fetcher.path('/list').method('get').create();
await list({});

json とそれに型を付ける jsonschema は言語に依存しない共通仕様みたいなものなので、これを取り回せると潰しが効きます。

TypeScript のみなら trpc というのもあるんですが、自分は使ったことがないので紹介するだけにしておきます。

https://trpc.io/

node:util's parseArgs

parseArgs は CLI 引数のバリデータです。node環境なら @types/node と一緒に使うことで、型を推論することが出来ます。

import { parseArgs } from "node:util";

const parsed = parseArgs({
  allowPositionals: true,
  args: process.argv.slice(2),
  options: {
    foo: {
      type: "string",
      short: "f",
    },
  } as const,
});
type Options = typeof parsed["values"];
//=> { foo: string | undefined; }

default を与えても string | undefined になってしまうのが惜しいんですが、自分は愛用しています。

Deno でも使えます。というか自分が Issue 書いて追加してもらいました。

https://github.com/denoland/deno/issues/20452

バリデータで大事なこと

色々な実装を紹介しましたが、大事なのは大本のスキーマ定義は一つである、という Single Source of Truth を意識することです。同じ対象に対して複数の場所で違うバリデータや型を定義すると、必ずどこかで食い違って事故が起きます。

また、環境によってバリデーションの用途は異なり、それによって異なるバリデーションの実装が要求されます。

  • サーバーのバリデーションはセキュリティのもの
  • フロントエンドでのバリデーション、サーバーに問い合わせることなく早期にフォームの入力エラーなどを発見するためのもので、セキュリティのためのものではない
  • フロントエンドはフロントエンドとしての状態があるので、サーバーから生成された型で全てを満たせるわけではない
  • OpenAI の Function Call で要求される jsonschema は AI に対するドキュメンテーションのためのもので description が型と同じぐらい大事

注意点として jsonschema と TypeScript や他の言語で表現できるセマンティクスは、厳密には1:1 に対応していません。例えば jsonschema の "type": "int" には数値の最大/最小を制限する min/max を記述できますが、これを型に落とし込むには依存型という特殊な型システムを持つ言語でないと表現できず、 TypeScript では単に number 型になります。というか JavaScript では整数値と浮動小数点の区別もないですね。

今回はバリデーションを取り上げましたが、型と値を意識してコードを書くのが TypeScript を安全に使うコツだと思います。

TypeScript でなぜ型を書くのか

そもそもなぜこんな制約下でTypeScriptを使うのかというと、型を自動検査できるドキュメントとして使いたいからです。個人的には、 TypeScript はコンパイラというより、型をチェックするリンターと考えたほうがしっくりくると考えています。

TypeScript は言語のデザインとして、安全性を捨ててでも豊富な表現力を取った言語です。これは変換先の JavaScript が動的型付けであって、型を考慮しない奔放なAPIやDSLが提供されるエコシステムだった、という現実(例: jQuery)に対し、それでも無理矢理に型を付ける表現力と、型を満たせなかった場合の脱出ハッチの両方が求められた結果です。ゼロから言語を作る場合は絶対にこの仕様にはなりませんが、現実に発生する需要は満たせています。

TypeScript の実装に何かしら一貫したポリシーがあるわけではなく、JavaScript の影として現実のユースケースを型の世界で解釈するために拡張されてきたという歴史があるので、そのユースケースを知らない初学者に教えるのがとにかく難しい言語だと思っています。

まとめ

  • TypeScript の型アノテーションは実行時に消えている
  • 型と値を同時に生成する class と enum は、 TypeScript としては特殊な存在
  • TypeScript で値のバリデーションをするには、値としてスキーマを定義するしかない
  • 値の推論結果から型を生成することはできるが、逆はできない
  • 型アノテーションは実行できる Linter

Discussion