💯

空でない配列を型で表現する正しい方法【TypeScript】

2023/08/01に公開
3

TypeScriptでは配列が空でないことを型レベルで表現できます。
この記事ではその型をNonEmptyArray<T>と書くことにします。

結論だけ先に書くと、次のように定義するのが正しいです。

export type NonEmptyArray<T> = [T, ...T[]] | [...T[], T]

現在ネット上では上記とは異なる、少し不具合のある型定義が紹介されているので要注意です。
それらも含めて簡単に解説します。

よくある間違いその1:T[] & { 0: T }

2つあるうち最初に紹介するのはこの型定義です。[1]

export type NonEmptyArray<T> = T[] & { 0: T }

{ 0: T }という型は「0番目の要素がT型であり、必ず存在する」ことを表します。
配列はオブジェクトでもあり、オブジェクトのプロパティ名に数値も使えるというTypeScriptの言語仕様を巧みに活かした型定義です。

しかしこの型定義ではスプレッド構文を使うと「空でない」という情報が型から消えてしまいます。
例えば次のように配列のシャローコピーを作ると型から{ 0: T }の部分が消えます。

type NonEmptyArray<T> = T[] & { 0: T }
// ↓string[]型になる😥 NonEmptyArray<string>型にはならない
type Spreaded = [...NonEmptyArray<string>]

const array: NonEmptyArray<string> = ['a', 'b']
// ↓型エラーになってしまう😥 [...array]はstring[]型と推論される
const copied: NonEmptyArray<string> = [...array]

なのでこの型定義は採用しない方がいいと思います。

よくある間違いその2:[T, ...T[]]

もう一つ紹介するのはスプレッド構文を使った次の型定義です。

export type NonEmptyArray<T> = [T, ...T[]]

ネット上で検索すると1つ目よりこちらの方が圧倒的に多くヒットします。当然Chat GPTもこのコードを提示してきます。

しかしこの型定義にも問題点があり、例えば以下のコードが型エラーになります

let array: number[] = [1, 2, 3]
// ↓型エラー😥
const appended: NonEmptyArray<number> = [...array, 4]

[...array, 4]はどう考えても空でない配列なので本来は型エラーが出てはいけません。
エラーの原因はTypeScriptが[T, ...T[]]型と[...T[], T]型を別物として扱い、お互いに代入不可だからです。
[...array, 4][...T[], T]のパターンなので、NonEmptyArrayの[T, ...T[]]とはパターンが異なります。
だから、配列の先頭に追加する[4, ...array]では型エラーは出ませんが、配列の末尾に追加する[...array, 4]では型エラーが出ます。

正しい方法

上記のような問題があるので次の型定義が最善、というのが私の結論です。

export type NonEmptyArray<T> = [T, ...T[]] | [...T[], T]

実は[...T[], T]という「スプレッド構文の後に普通の要素が続く型」はTypeScript 4.2で初めて書けるようになりました。
なのでそれ以前の時代はexport type NonEmptyArray<T> = [T, ...T[]]という定義が最善だったのだと思います。
TypeScriptの進化に合わせて型定義も改善していく必要があるということですね。

追記:注意点

Twitterで問題点をご指摘いただきました!
次のようにスプレッド構文を2つ使うパターンでは型エラーが起きてしまいます。

export type NonEmptyArray<T> = [T, ...T[]] | [...T[], T]
let array: number[] = [1, 2, 3]
// ↓型エラー
const doubleSpreaded: NonEmptyArray<number> = [...array, 4, ...array]

調べたところ、どうやらTypeScriptの機能不足が原因のようです。
実は現在のTypeScriptでは[...T[], T, ...T[]]というパターンの型を定義できません。
「型の中ではスプレッド構文は1回しか使えない」という制限があるようです。[2]
だからなのか、TypeScriptは[...array, 4, ...array]number[]型と推論しています。
そういうわけでTypeScript 5.1.6現在はNonEmptyArrayをどのように定義してもこのエラーは回避できそうにありません。

おまけ:長さN以上の配列の型は?

「空でない配列の型」は言い換えると「長さ1以上の配列の型」です。
NonEmptyArrayと同じ考え方で長さ2以上の配列の型を定義すると次のようになるでしょう。

[T, T, ...T[]] | [T, ...T[], T] | [...T[], T, T]

同じく長さ3以上の配列の型はこれです。もう手書きする気が起きない長さです…。

[T, T, T, ...T[]] | [T, T, ...T[], T] | [T, ...T[], T, T] | [...T[], T, T, T]

では引数としてNを取り、長さN以上の配列の型を生成する型レベル関数はどんな風に定義できるでしょうか?
難易度はtype-challengesでいうと上級くらいだと思います。
ぜひトライしてみてください〜!

脚注
  1. この型定義は、有名どころだとfp-tsというライブラリで採用されています。 ↩︎

  2. 特殊な例外として[...T[], T, ...any]は型エラーにはなりません。しかしこういう型はany[]として扱われるようなので無意味です。 ↩︎

chot Inc. tech blog

Discussion

kawarimidollkawarimidoll

参考になる記事をありがとうございます。
提示されている型定義だと、これがエラーになってしまう気がします↓

export type NonEmptyArray<T> = [T, ...T[]] | [...T[], T]
const array: number[] = [1, 2, 3]
const doubleArray: NonEmptyArray<number> = [...array, ...array]

現状、これを回避できる型定義をできるかわからないので、代案を出せないのですが、ご報告したいと思い、コメントしました。

ootideaootidea

ありがとうございます!
確かに現状だと対応できないパターンがあるようですね。
記事を修正しておきます〜

Hideaki NoshiroHideaki Noshiro

長さ2以上の配列の型

[T, T, ...T[]] | [T, ...T[], T] | [...T[], T, T]

も同様に、(少なくとも TypeScript v5.3 では許されていない構文ですが) [...T[], T, ...T[], T, ...T[]] を表現し切れていないので、自分なら readonly [T, T, ...T[]] に統一して妥協し、型ガード関数とキャスト関数を用意します。

const isArrayOfLength2OrMore = <A,>(
  array: readonly A[],
): array is readonly [A, A, ...A[]] => array.length >= 2;

const castArrayOfLength2OrMore = <A,>(
  array: readonly A[],
): readonly [A, A, ...A[]] => {
  if (isArrayOfLength2OrMore(array)) return array;

  throw new Error('Array is not of length 2 or more');
};

あと、なるべく多くの値を代入できる型を用意したいということであれば readonlyも付けた方が良いかなと思いました(型ガード関数の返り値にも readonly を付けるべきかは議論の余地がありますが、自分はアプリケーション全体で readonly を付けるべきだと考えているのでこのようにします)。

また、任意の長さの最小長を持つ配列型の定義は以下で定義できます。

type ArrayAtLeastLen<N extends number, Elm> = ArrayAtLeastLenRec<
  N,
  Elm,
  Elm[],
  []
>;

/** @internal */
type ArrayAtLeastLenRec<
  Num,
  Elm,
  T extends readonly unknown[],
  C extends readonly unknown[],
> = C extends { length: Num }
  ? T
  : ArrayAtLeastLenRec<
      Num,
      Elm,
      readonly [Elm, ...T],
      readonly [unknown, ...C]
    >;

type A = ArrayAtLeastLen<3, number>; // readonly [number, number, number, ...number[]]

コードの分かりやすさを重視した愚直な再帰なので、 N を大きくしたい場合にはさらに工夫が必要になります(以下の記事などが参考になります)。

https://techracho.bpsinc.jp/yoshi/2020_09_04/97108