tekiehei2317's blog

今日のプログラミング: prisma-fabbricaとTypeScriptのタプル

投稿日: 2023-02-28

prisma-fabbricaというファクトリのライブラリを使って、次のようなテストコードを書いていました。

const user = await UserFactory.create(); const [post1, post2] = await PorstFactory.createList([ { user: { connect: user.id } }, { user: { connect: user.id } }, ])

noUncheckedIndexedAccessをtrueにしている場合、post1post2Post | undefinedになってしまう問題があります。PostFactory.createListの戻り値がPost[]型であるためです。

戻り値を[Post, Post]にするべきなのかなと一瞬思いましたが、PrismaのcreateManyの戻り値が配列であることを考えると、ライブラリの責任の範囲を超えていそうです。そのため、使用側でなんとかすることにしました。

ts-array-length

最近uhyoさんがts-array-lengthというライブラリを公開されていたことを思い出しました。ts-array-lengthは、配列をタプルに絞り込むためのユーティリティです。

import { hasLength } from 'ts-array-length' // const arr: string[] if (hasLength(arr, 1)) { // arr: readonly [string, string] const str: string = arr[0]; }

hasLengthの実装は以下のようになっています。いわゆるユーザー定義型ガードです。

export function hasLength<T, N extends number>( arr: readonly T[], length: N, ): arr is ReadonlyArrayExactLength<T, N> { return arr.length === length; }

テストの中では条件分岐を書くよりアサーションをしたほうがいい気がするので、上記を参考にしてアサーション関数を作ります(まだ試せていない)。

export function assertHasLength<T, N extends number>( arr: readonly T[], length: N, ): asserts arr is ReadonlyArrayExactLength<T, N> { if (arr.length !== length) { throw new Error(`Array length is expected to be ${length}, but got ${arr.length}`); } }

これを使うと、初めのテストコードは以下のように修正できます。

const user = await UserFactory.create(); const posts = await PorstFactory.createList([ { user: { connect: user.id } }, { user: { connect: user.id } }, ]) assertHasLength(posts, 2); // post1: Post, post2: Post const [post1, post2] = posts;

仕組みを調べる

さて、気になるのはReadonlyArrayExactLength<T, N>型です。これは名前の通り、要素がTで長さがNのreadonlyなタプルを作る型です。

// readonly [string, string] ReadonlyArrayExactLength<string, 2>

実装は次のようになっていました。ユーティリティ型にあってもいいような気がするので、追加されると嬉しいですね。

ts-array-length/types.ts at main · uhyo/ts-array-length

export type ReadonlyArrayExactLength<T, N extends number> = N extends number ? number extends N ? readonly T[] : IsCertainlyInteger<N> extends true ? ReadonlyArrayExactLengthRec<T, N, readonly []> : readonly T[] : never; type ReadonlyArrayExactLengthRec< T, L extends number, Result extends readonly T[], > = Result["length"] extends L ? Result : ReadonlyArrayExactLengthRec<T, L, readonly [T, ...Result]>;

IsCertainlyInteger<N>は、Nが非負整数のリテラル型であるかどうかを判定しています。これは、マイナスの数や少数を渡したときに無限ループを防ぐためのものです。

ts-array-length/typeUtils.ts at main · uhyo/ts-array-length

分かりやすくするために簡略化したものが以下です。

type Tuple<Value, Length extends number, Result extends Value[] = []> = Result['length'] extends Length ? Result : Tuple<Value, Length, [...Result, Value]> // Test: [string, string, string] type Test = Tuple<string, 3>

使われているのは、Array['length']を使って再帰するテクニックです。1 | 2 | 3 | ...のような型もこのテクニックを使うと作れます。

参考: Generate literal type for numbers range in TypeScript

横道にそれる

最初に諦めましたが、PostFactoryの戻り値をタプルにする方法を考えてみます。そのためには、長さNのCreatePostInputのタプルを受け取り、長さNのPostのタプル返す関数を定義する必要があります。

引数をタプルとして受け取るには、Tを配列型として[...T]のようにします。

function createList<T extends CreatePostInput[]>(inputs: [...T]): Tuple<Post, T['length']> { return inputs.map((input, index) => ({ ...input, id: index + 1})) as Tuple<Post, T['length']>; } // posts: [Post, Post] const posts = createList([{ title: 'test' }, { title: 'test' }])

参考: TypeScript 4.0で導入されるVariadic Tuple Typesをさっそく使いこなす - Qiita

array.mapの戻り値をタプルにする方法が分からなかったので、もし分かる方がいらっしゃったら教えていただけると助かります。

まとめ

  • noUncheckedIndexedAccessがtrueの場合は、ts-array-lengthを使うと配列からタプルへ絞り込める
  • 長さがNのタプルを作るユーティリティ型は存在しないので、再帰型定義を使って自作する必要がある
  • 引数を配列ではなくタプルとして受け取る場合は、引数の型を[...T]とする(Tは配列型)
;