🌟

展開したreadonly配列とextendsの話

に公開

はじめに

皆さん型を捏ねるのはお好きですか?
僕は最近捏ね始めた初心者なのですが、何やら最近型の再帰上限が上がったらしいですね。
いろんなことができて面白いなと思っている次第です。

ox ゲームや数独を作っていらっしゃる方もいるようですね...
型をつかいこなせていてかっこいい...

僕は体系的に学ぶのが得意でないので場当たり的に調べては試してを繰り返していて、数日前に面白い型を作ることができ記事にしました。

https://zenn.dev/yossuli/articles/eb3e471d954c15

その記事の中では、やりたいことはできていたので深堀はしなかった TS の型でよくわからないところについて、その疑問を深堀して解決することができたので、本記事は備忘録的に書いています。

語句

extends とは

extends は TypeScript の型システムにおいていくつかの意味があります

  • 型の継承
  • 条件付き型
  • 型の制約

今回関連するのは条件付き型です。
条件付き型は、ある型が別の型に適合するかどうかをチェックし、その結果に応じて異なる型を返すことができます。

type Example<T> = T extends string ? number : boolean;
type test1 = Example<string>; // number
type test2 = Example<number>; // boolean

ざっくりと、i 項目で extends 句の左辺を右辺に割り当ててエラーにならなければ 2 項目が返り、エラーになるようなときは 3 項目が返るというのが僕の認識です。

infer とは

infer は TypeScript の型システムにおいて、型の一部を推論するためのキーワードです。
infer を使用すると、型の一部を動的に決定することができます。
例えば、関数の引数の型を推論したり、条件付き型の中で型を抽出したりすることができます。

type Example<T> = T extends (infer U)[] ? U : never;

type test = Example<string[]>; // string
type notArray = Example<string>; // never

ざっくりと、infer はパターンマッチを行った結果に対し、infer の部分にマッチした型に新しく名前をつけて再利用可能にするものというのが僕の認識です。

as const とは

as const は TypeScript の構文で、オブジェクトや配列をリテラル型として扱うために使用されます。
as const を使用すると、オブジェクトや配列のプロパティや要素が変更されないことを TypeScript に明示的に伝えることができます。

const arr = [1, 2, 3] as const;
type Test = typeof arr; // readonly [1, 2, 3]

ざっくり、ホバーした時に分かりやすくていいなぁくらいの認識(でした)。

発生した問題

記事の中では、配列に重複がないことを保証する型ガード関数を作成しました。
この配列は定数として使用するものなので as const で宣言していました。
しかし、as const で宣言した配列を T extends string[] で制限した型引数に渡す際に 型 'readonly [...]' は 'readonly' であるため、変更可能な型 'string[]' に代入することはできません。と永遠に怒られ続けたために、何も理解せずにとりあえず T extends readonly string[] と書き換えました。

type FilterTarget<
  T extends string,
  Array extends readonly string[]
> = Array extends [infer U, ...infer V extends string[]]
  ? U extends T
    ? [U, ...FilterTarget<T, V>]
    : FilterTarget<T, V>
  : [];

export type EnsureUniqueStrArr<T extends readonly string[]> = {
  [K in keyof T]: FilterTarget<T[K], T>["length"] extends 1 ? T[K] : never;
};

export const uniqueStrArr = <T extends readonly string[]>(
  arr: EnsureUniqueStrArr<[...T]>, // [1]
  arr: EnsureUniqueStrArr<T>       // [2]
): T => arr;

// [1]
uniqueStrArr(["a", "b", "c", "a"] as const);
// ["a", "b", "c", "a"] を [never, "b", "c", never] に割り当てられないという予期するエラー

// [2]
uniqueStrArr(["a", "b", "c", "a"]);
// ["a", "b", "c", "a"] を [never, never, never, never] に割り当てられないという予期しないエラー

なぜ[...T]と書くとエラーにならないのか...

原因究明過程

とりあえず AI に聞いてみる

[...T] を使用することで、ジェネリック型 TEnsureUniqueStrArr の内部で評価される前に、より具体的なタプル構造として解決されることを促していると考えられます。これにより、T[K] がリテラル型として扱われ、FilterTarget が意図通りに機能するようになります。
これは TypeScript の型推論と評価戦略の高度な部分であり、ジェネリック型パラメータが複雑な条件型やマップ型とどのように相互作用するかによって挙動が変わることがあります。[...T] のような構文は、型解決のタイミングや方法に影響を与える「ヒント」として機能することがあります。

ほーん。なるほどわからん。
簡単に説明しなおさせてる間に別のアプローチとろ~

とりあえず小さく分割していろいろ試すか。

type EnsureUniqueStrArrTest<T extends readonly string[]> = {
  [K in keyof T]: T[K] extends "b" | "c" ? T[K] : never;
};
type test = EnsureUniqueStrArrTest<readonly ["a", "b", "c", "a"]>;
// [never, "b", "c", never] -> ok

それは通るよね。

type test = FilterTarget<"a", readonly ["a", "b", "c", "a"]>;
// [] -> NG !!!!

あれ??

type test = FilterTarget<"a", ["a", "b", "c", "a"]>;
// ["a", "a"] -> ok

readonlyが怪しいのか??

結論

以前の記事のコメントにて原因について教えてくださっている方がいらっしゃいました
コメントの有無を Zenn の UI のどこで確認すればよいか分からず見落として、コメントよりも後に記事を公開してしまって申し訳ありません...

https://zenn.dev/link/comments/48625e728345dc

おまけの部分について、原因がわかったのでコメントいたします。
結論から言うと、FilterTarget を次のようにすれば解決します。

type FilterTarget<
T extends string,
Array extends readonly string[]
= Array extends readonly [infer U, ...infer V extends string[]]
? U extends T
   ? [U, ...FilterTarget<T, V>]
   : FilterTarget<T, V>
 : [];

つまり、readonly ["a", "b", "c", "a"]は U と V をどのようにとっても[infer U, ...infer V extends string[]]の部分型にはならない(readonly な配列型はそうでない配列型の部分型にならない)ため、元の実装では Array として readonly な型を渡すとここで常に条件を満たさない判定になっています。
後段の uniqueStrArr の実装では、[...T]としたことで readonly が外れてうまく動いているものと思われます。

簡潔な説明で分かりやすかったです。ありがとうございます!

以下は僕がくどくど書いてるだけなので読んでいただかなくて大丈夫です。

旧版

readonly any[] と any[] の互換性(by Gemini)

readonly any[]any[] の間には、以下のような互換性があります。

  • any[]readonly any[] に代入できる(共変性)
    通常の(変更可能な)配列は、読み取り専用の配列として扱うことができます。
    これは、読み取り専用のコンテキストで可変な配列を使用しても、その可変な配列が意図せず変更されることはないため、安全な操作だからです。
let mutableArr: any[] = [1, 2, 3];
let readonlyArr: readonly any[];

readonlyArr = mutableArr; // OK: 可変な配列を読み取り専用の変数に代入できる
  • readonly any[]any[] に代入できない(非変性)
    読み取り専用の配列を通常の(変更可能な)配列として扱うことはできません。
    なぜなら、読み取り専用の配列を可変な配列として扱ってしまうと、本来許されない変更操作が可能になってしまい、型の安全性が失われるからです。

    let readonlyArr: readonly any[] = [1, 2, 3];
    let mutableArr: any[];
    mutableArr = readonlyArr;
    // エラー: Type 'readonly any[]' is 'readonly' and cannot be assigned to the mutable type 'any[]'.
    

原因の考察

readonly any[]any[] に代入できないことから、T extends string[] では Treadonly である場合にエラーが発生することになります。
今回は as const で宣言した配列を T extends string[] に渡しているため、Treadonly string[] となります。
そのため、T extends string[] の部分で マッチしないため never が返されていました。
[...T] と書くことで、T の型が readonly string[] から string[] に変換されるため、マッチするようになりエラーが発生しなくなります。

解決策

今回はreadonly な配列を受け入れる必要があるため、T extends readonly string[] と書くべきでした。

type FilterTarget<
  T extends string,
  Array extends readonly string[]
> = Array extends readonly [infer U, ...infer V extends string[]]
  ? U extends T
    ? [U, ...FilterTarget<T, V>]
    : FilterTarget<T, V>
  : [];

export type EnsureUniqueStrArr<T extends readonly string[]> = {
  [K in keyof T]: FilterTarget<T[K], T>["length"] extends 1 ? T[K] : never;
};

export const uniqueStrArr = <T extends string[]>(
  arr: EnsureUniqueStrArr<T>
): T => arr;

これで、as const で宣言した配列を引数に渡せば予期している通りのエラーが発生します。

おわりに

  • 小さく区切って考えるのは大事
  • AI は万能じゃない
  • なんだかんだ型たのし~

Discussion