🦈

Cline が一発で解いた TypeScript 型パズルを解読する

に公開

こんばんは、時間決めてすみません。社会人大学院生バンドマンの小林と申します。最近 TypeScript を書いていたら遭遇した面白い型エラーについて紹介しようと思います。
そこまで複雑な型パズルではないんだけど、そこそこ面白いから面白そうだな〜と思った人は是非具体的な例の部分のコードを TypeScript Playground にコピペして遊んでみてね。

発端

ライブラリの関数を呼び出してそこそこ長い引数を渡そうとしていたときに、

functionA([
   {
      type: 'foo',
      idA: 1,
   },
   {
      type: 'bar',
      idB: 2,
   }
]);

はコンパイルが通るのに、コードを整理して

const arg = [
   {
      type: 'foo',
      idA: 1,
   },
   {
      type: 'bar',
      idB: 2,
   }
];

functionA(arg);

はエラーが出てコンパイルが通らない。変数として切り出しただけだから通ってほしいんだけど、実際には型エラーが出ちゃう。でこれがなかなか曲者だったんだけど、AI Coding Agent の Cline に投げたら一発で解決してきてビックリしちゃった。

具体的な例

type Shape =
  | { type: 'circle'; radius: number }
  | { type: 'square'; sideLength: number }
  | { type: 'rectangle'; width: number; height: number };

type ShapesArray = Shape[];
function calculateTotalArea(shapes: ShapesArray): number {
  let totalArea = 0;
  for (const shape of shapes) {
    switch (shape.type) {
      case 'circle':
        totalArea += Math.PI * shape.radius * shape.radius;
        break;
      case 'square':
        totalArea += shape.sideLength * shape.sideLength;
        break;
      case 'rectangle':
        totalArea += shape.width * shape.height;
        break;
    }
  }
  return totalArea;
}

というコードがあったとしよう。このコードでは丸、正方形、四角の型を定義して、形の配列を受け取ると合計の面積を返す関数が定義されている。このとき、単純に引数に全部書いて渡すと、当然問題なく計算することができる。

console.log(calculateTotalArea([
   { type: 'circle', radius: 5 },
   { type: 'square', sideLength: 4 },
]);

で、まあこのコードだと図形が増えていったときに見づらいな〜と思うから変数に切り出して別の場所に置いとこうと思うわけだ。素直にやるとこんな感じ。


const shapes1 = [
  { type: 'circle', radius: 5 },
  { type: 'square', sideLength: 4 },
];

console.log(calculateTotalArea(shapes1));

ところがどっこいこれだと型エラーがでちゃう。最近は Cline に投げた後に自分でも考えることにしてるから、とりあえず Cline に投げたらなんと一発解決してきた。そのコードがこれ:

const shapes2 = [
  { type: 'circle', radius: 5 },
  { type: 'square', sideLength: 4 },
] as const;

console.log(calculateTotalArea([...shapes2]));

TypeScript Playground に今までのコードをコピべしてもらうとわかるんだけど、これで何故かエラーが消える。as const と配列のスプレッド演算子、どちらがなくてもエラーは残ったままになってしまう。

なぜこのコードで解決できるのか

Literal Type

これだけだとまだわかんないから、まず最初に変数に切り出したときのエラーを見てみる。

Argument of type '({ type: string; radius: number; sideLength?: undefined; } | { type: string; sideLength: number; radius?: undefined; })[]' is not assignable to parameter of type 'ShapesArray'.
  Type '{ type: string; radius: number; sideLength?: undefined; } | { type: string; sideLength: number; radius?: undefined; }' is not assignable to type 'Shape'.
    Type '{ type: string; radius: number; sideLength?: undefined; }' is not assignable to type 'Shape'.
      Type '{ type: string; radius: number; sideLength?: undefined; }' is not assignable to type '{ type: "square"; sideLength: number; }'.
        Types of property 'type' are incompatible.
          Type 'string' is not assignable to type '"square"'.

この小さな変更でエラーが出るということはおそらく型推論がうまく行っていないのだろうという気がしてくる。実際にエラーを見ると、typeプロパティをstringと解釈してしまっている部分に問題があるように見える。

配列を宣言するとき特に型を指定していないので TypeScript は型注釈がある場合とくらべてより一般的に解釈して、type: 'circle'string型として解釈される。type'circle', 'square', 'rectangle'のどれかを受け付けることになってるけど、宣言時点の型推論では単なるstring型として解釈されるのでこれだと型が合わないことになってしまう。当然、適当に変数を宣言したときにstringが全部イミュータブルになって編集できなくても困るから理解できる挙動だよね。

まあでも今回はstringとして解釈してほしいのでas constをつけようという気持ちになる。as constをつけることで変数は Deeply Immutable になる。配列・オブジェクトが Immmutable であると言っても複数の状態が考えられて、単に再代入や配列への要素の追加などが許されないという状態と、プロパティまで再帰的にReadonlyである状態の2つが考えられる。前者を Shallow Immutable、後者を Deeply Immutable であると呼ぶ。オブジェクトのプロパティ、今回でいうとtyperadiusまでreadonlyになるので、より厳密な型で解釈されることが期待される。で、実際これをやると、typeプロパティの型はより厳密な'circle'という String Literal として解釈される。

面白くて、ここでは単なるstringというよりは'circle'という文字列という型があり、それがstring型に包括されるというような形になるんだよね。ここでの'circle'みたいな単一の文字列の型を String Literal Type (文字列リテラル型)を呼び、型としては 'circle' という String Literal になるけど、string型に代入することもできる。逆は、コンパイル時にstring型を持つ変数がその String Literal と同じ文字列を持っていることが保証できないので代入できない。このように String Literal はstring型により強い制約を加えたものであり、stringという型の集合の一部、subtype (部分型)であると呼ばれる。Go をホームとしているプログラマーからすると、型に集合という概念が当て嵌められるのはだいぶ柔軟で面白い。Go にはそもそも Union 型すらないからね。

これに関連する話は TypeScript の公式ドキュメントにも登場している(https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#literal-inference)。リテラル型の話など面白い話が他にも載っているので、あとで読んでみると良いと思う。

Immutability

さて、これで type の型問題は解決するけど、そうすると別のエラーが出てくる:

Argument of type 'readonly [{ readonly type: "circle"; readonly radius: 5; }, { readonly type: "square"; readonly sideLength: 4; }]' is not assignable to parameter of type 'ShapesArray'.
  The type 'readonly [{ readonly type: "circle"; readonly radius: 5; }, { readonly type: "square"; readonly sideLength: 4; }]' is 'readonly' and cannot be assigned to the mutable type 'ShapesArray'.

今度はShapesArrayreadonlyな配列は受け付けられないと文句を言ってくる。

何が起きているかというと、as constによって配列が Immutable になって、配列自体も要素の追加などができないreadonly arrayになっている。as constは配列全体にかかっているので当然期待される挙動ではあるんだけど、これを関数に突っ込むとなると問題が出てくる。関数の引数の型はShape[]になっていて、これはArray<Shape>と同じものを指す。一方で、as constを付けた配列はReadonlyArray<Shape>になってしまう。ReadonlyArray<T>に対して行うことのできる動作は当然Array<T>に対して行うこともできるのでArray<T>ReadonlyArray<T>の subtype であると言えるが、逆はもちろんできないので今回のようにエラーが出ることになる。普通に考えて関数に配列を突っ込むときは要素の操作が絡む場合も当然あり、そこに ReadonlyArray を突っ込まれたら操作を行うとエラーが出てしまうため型エラーが出てもらわないと困る、まあちょっと考えると妥当な動作であることがわかる。

ただ今回はas constをつけないとtypeプロパティが String Literal として解釈されないので困ってしまう。それぞれのtypeプロパティにas constを付けても良いんですがめんどくさい、そこでスプレッド演算子が登場するわけなんですね。

const shapes2 = [
  { type: 'circle', radius: 5 },
  { type: 'square', sideLength: 4 },
] as const;

console.log(calculateTotalArea([...shapes2]));

スプレッド演算子をつけて配列に入れることで何が起きているかというと、要素のイミュータビリティは保持したまま配列を分解して再構築することで配列自体のイミュータビリティを外すということを行っている。これによって要素のtypeプロパティは String Literal として解釈されたまま配列は単なるArrayになり、無事関数に突っ込んで計算することができるようになるわけだ。

Discriminated Union Type

ちなみに、今回のShapesのようなプロパティの1つがユニオン型になっていて、それでオブジェクトの型を判定できるような型のことを Discriminated Union Type と呼ぶらしい。ここで条件分岐を書くことによってswitchで型による処理が容易になるなどの利点がある。Enum のように扱えるのでswitchを書くときにはUnion型で許されている型の分岐しか書かなくて良いかと思いがちだけど、ここは TypeScript の世界なので JavaScript の事情など様々なものを考慮してdefaultの分岐を書かなきゃいけない。非常に残念だけど、実は面白い書き方があって

switch(shape.type) {
	case 'circle':
		// 円の場合の処理
		break;
	case 'square':
	  // 正方形の場合の処理
	  break;
	case 'rectangle':
	  // 長方形の場合の処理
	  break;
	default:
		const _: never = shape;
}

のようにdefaultneverの型注釈を付けた変数にshapeを代入するという操作をすることで、Union型へ新たな型が追加された場合にコンパイルエラーを出すことができる。惰性で書いていたdefault分岐が将来のために有効になるということで、まあ許してやるか、という気持ちになれるのでおすすめ。

まとめ

特定の String Literal を要求する Discriminated Union Type を引数に受け付ける関数に変数を引数として渡す場合、as constで宣言した上でスプレッド演算子を利用しないとそれぞれの要素の型推定と引数としての受け渡しがうまく行かないということがわかった。例として図形の面積の計算を行うコードを出したが、実際遭遇したのはもうちょっと複雑なコードで、自動生成されたコードの型のエクスポートがされていなかったので今回のような解決策が有効だった。型がエクスポートされているなら素直に型注釈を付けてあげれば良いという話ではあるけど、型パズルもたまにやると面白いよね。

正直な話、Cline に投げてこれが返ってきたときはなんか余計なことをやってるだろうと思ったけど、見てけば見てくほど勉強になってもう失業秒読みなんじゃないかと思った。この例示している図形の面積を計算するコードも全部 Cline に書かせてるから、実質的にブログもAI作みたいなもんなんだよな。Claude はもっと賢くて便利で、しかも8並列同時で動かしてる人とかいたしもうソフトウェアエンジニアって職業は無くなっちゃうかもわかんないね。そしたらミュージシャンでやっていこうと思うので、そのときにはぜひライブに遊びに来てください。

Discussion