🧪

io-tsと反変・共変

2024/10/03に公開

前の記事では、サブタイピング機能を備える静的型付け言語につきものの、抽象度が高く難しい概念「反変」と「共変」について解説しました。この記事では、前回の記事を書く動機となった気づきについてお話します。最初に前提として「反変」と「共変」を組み合わせた概念である「不変」を紹介し、その上で「不変」であることによって発覚した、その「気づき」について解説します。

不変: 関数の引数として受け取り、かつ戻り値として返す場合

ある型の型引数が、関数の戻り値であれば共変になり、関数の引数であれば反変になるということは、型引数が関数の戻り値と引数両方で使用されていた場合はどうなるでしょうか。そうした場合は「不変」となり、型引数に渡す型が全く同じでなければサブタイプ・スーパータイプの関係が成立しなくなります。文字通り「不変」なのです。

早速例を挙げましょう:

// 型変数Tを関数の戻り値と引数両方に使用している型
interface Invariant<T> {
  receiveReturnValue: (value: T) => T;
}

const inv1Bad1: Invariant<string> = {
  receiveReturnValue: (value: string): Object => value
};
// 型エラー: Type '(value: string) => Object' is not assignable to type '(value: string) => string'.
//             Type 'Object' is not assignable to type 'string'.
//             (戻り値の型 Object が string のサブタイプではないので)

const inv1Bad2: Invariant<Object> = {
  receiveReturnValue: (value: string): Object => value
};
// 型エラー: Type '(value: string) => Object' is not assignable to type '(value: Object) => Object'.
//             Types of parameters 'value' and 'value' are incompatible.
//               Type 'Object' is not assignable to type 'string'.
//               (引数の型 string が Object のスーパータイプではないので)

Invariantでは、receiveReturnValueというプロパティーの関数において、型引数Tを引数と戻り値両方で使用しているため、Tの型が全く同じでなければサブタイプ・スーパータイプの関係に該当することがありません。

なお、次のInvariantSub型のようにInvariantそのもののサブタイプとなっている型の場合、Invariant<string>の型にInvariantSub<string>の値を代入することはできます:

interface InvariantSub<T> extends Invariant<T> {
  anotherValue: number;
}

const invSub: InvariantSub<string> = {
  receiveReturnValue: (value: string) => value,
  anotherValue: 1,
};

const inv1: Invariant<string> = invSub;
// 型エラーなし!

ここまでのコードをTypeScript Playgroundで試す

型変数Tについてはどちらも同じ型なので問題なく、InvariantInvariantSubにおける、型変数Tとは関係ない部分についても検証した結果、InvariantSubInvariantのサブタイプであると判断されたのです。

io-tsと不変

前職時代に会社のブログで考案したTypeScript版「Trees that Grow」をとあるプロジェクトに適用したところ、io-tsというライブラリーを併用していた関係で、io-tsのType型が型引数に対して「不変」となっていたためうまく適用できない、という問題に出くわしました。

io-tsは、例えるなら今やお馴染みのzodに、エンコード機能(JSONへのシリアライズなどに使う)を付け加えたものだと考えてください。io-tsにおけるType型は、すごく単純化すると次のようなものとなっています:

interface Type<T, O = T> {
  decode(object: unknown): T;
  encode(value: T): O;
}

decodeは(外部からの入力によって得られた)unknownな値を、プログラムで扱いたいT型の値に変換します。もう一つのencodeは、プログラムで扱っているT型の値を(例えば、JSONなどに変換しやすい)別の型Oに変換します。Type型はこのように、T型を戻り値にする関数と、T型を引数にする関数両方のペアを持つことによって「型」を表現しています。私が利用したプロジェクトにおいても、状態をJSONにencodeして保存したりJSONからdecodeして読み込んだりする必要があったため、この仕様は好都合でした。

ただ、このような仕様のため、Typeは型引数Tについて「不変」なのです。したがって、Type<T, O>を扱う処理を書く場合、Type<T, O>Tについては必ず同じ型にならなければならず、抽象化しにくくなっています。

io-tsの大抵のユースケースでこれは問題にならないのですが、先ほど触れたとおり TypeScript版「Trees that Grow」を適用するに当たっては致命的な障壁となりました。

具体例として、ひとまずTypeScript版「Trees that Grow」の記事で紹介した型を簡略化して再利用します:

interface ExtendExpression {
  Literal: object;
  Func: object;
}
type Expression<Extend extends ExtendExpression> =
  | Literal<Extend>
  | Func<Extend>
type Literal<Extend extends ExtendExpression> = {
  type: "Literal";
  value: number;
} & Extend["Literal"];
type Func<Extend extends ExtendExpression> = {
  type: "Func";
  argumentName: string;
  body: Expression<Extend>;
} & Extend["Func"];

// Func型のみ拡張する例
//   Expression 型に ExtendExpression を実装した WithArity を渡すことで、
//   Func 型のみが拡張された Expression 型を作成する
interface WithArity {
  Literal: object;
  Func: {
    arity: number;
  };
}
type ExpressionWithArity = Expression<WithArity>;

const example: ExpressionWithArity = {
  type: "Func",
  argumentName: "x",
  body: {
    type: "Literal",
    value: 42,
  },
  arity: 1,
};

ここまでのコードをTypeScript Playgroundで試す

これを、io-tsのTypeを用いて定義するなら、次のような定義になるでしょう:

import * as t from 'io-ts';

// ... ここにTypeScriptの型の構文でも型を定義する必要がありますが、省略 ...

const ExtendExpression = t.type({
  Literal: t.type({
    // ここにLiteral型に追加するプロパティーを定義
  }),
  Func: t.type({
    // ここにFunc型に追加するプロパティーを定義
  }),
});

const Expression = <Extend extends typeof ExtendExpression>(
  extend: Extend,
): t.Type<Expression<t.TypeOf<Extend>>> =>
  t.recursion('Expression', () =>
    t.union([Literal(extend), Func(extend)]),
  );

const Literal = <Extend extends typeof ExtendExpression>(
  extend: Extend,
): t.Type<Literal<t.TypeOf<Extend>>> =>
  t.intersection([
    t.type({
      type: t.literal("Literal"),
      value: t.number,
    }),
    extend.props.Literal,
  ]);

const Func = <Extend extends typeof ExtendExpression>(
  extend: Extend,
): t.Type<Func<t.TypeOf<Extend>>> =>
  t.intersection([
    t.type({
      type: t.literal("Func"),
      argumentName: t.string,
      body: Expression(extend),
    }),
    extend.props.Func,
  ]);

ここまでのコードをTypeScript Playgroundで試す

大分TypeScriptの構文から乖離していて分かりづらいかも知れませんが、io-tsの詳細はここでは割愛します。詳しくは執筆時点のio-ts安定版が提供するドキュメントを御覧ください。TypeScriptの型の構文との対比も載っているのでわかりやすいでしょう。重要なところは、Expression型やその構成要素であるLiteral型やFunc型が、対応するt.Type型を返す関数として定義されていることです。オリジナルの型であるExpression型は型引数を受け取る型なので、io-tsのt.Type型として表現する際は、t.Type型の値を引数として受け取る関数として定義しています。

上記のとおりExpression型を定義した段階では特に型エラーは起こりません。ところが、いざExtendExpressionインターフェイスを実装したWithArity型を定義してExpressionにわたすと、型エラーが起きてしまいます:

// io-tsでもFunc型のみ拡張する例
//   Expression 型に ExtendExpression を実装した WithArity を渡すことで、
//   Func 型のみが拡張された Expression 型を作成する
const WithArity = t.type({
  Literal: t.type({}),
  Func: t.type({
    arity: t.number,
  }),
});
const ExpressionWithArity = Expression(WithArity);
// 型エラー: Argument of type 'TypeC<{ Literal: TypeC<{}>; Func: TypeC<{ arity: NumberC; }>; }>' is not assignable to parameter of type 'TypeC<{ Literal: TypeC<{}>; Func: TypeC<{}>; }>'.
//             The types of 'props.Func.encode' are incompatible between these types.
//               Type 'Encode<{ arity: number; }, { arity: number; }>' is not assignable to type 'Encode<{}, {}>'.
//                 Property 'arity' is missing in type '{}' but required in type '{ arity: number; }'.

ここまでのコードをTypeScript Playgroundで試す

これは先程触れた通り、Type型が(最初の)型引数に対して「不変」であるため、t.Type型に間接的に渡すExtend型引数も不変となり、その結果一切拡張していないExtendExpression型しか受け取れなくなってしまうためです。エラーメッセージをよく読むと、(t.Type型が実装している)Encodeインターフェイスについてのエラーであることが分かります。Encodeインターフェイスは、この節の冒頭で単純化して紹介したType型(下記再掲)でいうところのencodeメソッドを提供するものです。encodeメソッドは型引数T型の値を引数として受け取っているため、型引数Tに対して「反変」です。したがって、ExtendExpressionのサブタイプに当たるWithArity型を含む、ExpressionWithArity型の値を渡すことができないのです。

interface Type<T, O = T> {
  decode(object: unknown): T;
  encode(value: T): O;
}

まとめ・所感

以上のとおりio-tsのType型は、表現している型の値をエンコードする(Tの値を受け取って他の型を返す)関数と、デコードする(他の型の値を受け取ってT型の値を返す)関数を、一つのオブジェクトにまとめている関係上、表現している型を現す型引数Tに対して「不変」となってしまいます。結果、型引数を受け取ることで抽象化した型を作ろうとしても、その型引数も不変となってしまうので、実質的に一つの型しか受け取らない、不便な抽象化となってしまう恐れがあります。特に私が経験したように「TypeScript版 Trees that Grow」を適用しようとした際は、致命的な障害となりました。zodにエンコードの機能がないのは、そうした事情があるのではないかと推測されます。

GitHubで編集を提案

Discussion