io-tsと反変・共変
前の記事では、サブタイピング機能を備える静的型付け言語につきものの、抽象度が高く難しい概念「反変」と「共変」について解説しました。この記事では、前回の記事を書く動機となった気づきについてお話します。最初に前提として「反変」と「共変」を組み合わせた概念である「不変」を紹介し、その上で「不変」であることによって発覚した、その「気づき」について解説します。
不変: 関数の引数として受け取り、かつ戻り値として返す場合
ある型の型引数が、関数の戻り値であれば共変になり、関数の引数であれば反変になるということは、型引数が関数の戻り値と引数両方で使用されていた場合はどうなるでしょうか。そうした場合は「不変」となり、型引数に渡す型が全く同じでなければサブタイプ・スーパータイプの関係が成立しなくなります。文字通り「不変」なのです。
早速例を挙げましょう:
// 型変数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
についてはどちらも同じ型なので問題なく、Invariant
とInvariantSub
における、型変数T
とは関係ない部分についても検証した結果、InvariantSub
はInvariant
のサブタイプであると判断されたのです。
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にエンコードの機能がないのは、そうした事情があるのではないかと推測されます。
Discussion