避免有区别的联合的代码重复

Avoiding code repetition with discriminated unions

说我有这个功能

async function getTracksOrAlbums(
  tracksOrAlbums: {tracks: Track[]} | {albums: Album[]},
  notFound: NotFound,
): Promise<GetTracksOrAlbums> {
  if ('albums' in tracksOrAlbums) {
    const albumsResponse = await getManyAlbums(tracksOrAlbums.albums)

    const result = albumsResponse.filter((item, i): item is AlbumResponse => {
      return filterErrors(() => {
        notFound.total++
        notFound.data.push(tracksOrAlbums.albums[i])
      }, item)
    })
    return {
      data: result,
      report: {found: result.length, notFound},
    }
  }

  const tracksResponse = await getManyTracks(tracksOrAlbums.tracks)
  const result = tracksResponse.filter((item, i): item is TrackResponse => {
    return filterErrors(() => {
      notFound.total++
      notFound.data.push(tracksOrAlbums.tracks[i])
    }, item)
  })

  return {
    data: result,
    report: {found: result.length, notFound},
  }
}

我想抽象以下部分到一个单独的函数中:

const albumsResponse = await getManyAlbums(tracksOrAlbums.albums)
const result = albumsResponse.filter((item, i): item is AlbumResponse => {
  return filterErrors(() => {
    notFound.total++
    notFound.data.push(tracksOrAlbums.albums[i])
  }, item)
})

几乎伪代码我想做一些事情:

async function filterTracksOrAlbums<T extends Track | Album>(
  tracksOrAlbums: Array<T>,
  notFound: NotFound,
) {
  const searchRes = await getManyTracks(tracksOrAlbums) // or await getManyAlbums(tracksOrAlbums)
  const result = searchRes.filter(
    // I don't really know how to define here a generic type guard
    (item, i): item is T => {
      //
      return filterErrors(() => {
        notFound.total++
        notFound.data.push(tracksOrAlbums[i])
      }, item)
    },
  )
  return result
}

我还想表达输入之间的关系

(Track | Album)[]

并输出:

(TrackResponse | AlbumResponse)[]

不会引起重复,因为如果我要为 playlistsuser data[= 添加另一个案例51=], 每次 filter 回调和它对应的 类型时,我都必须重写 guard 匹配可能的 PlaylistResponseUserResponse

有没有一种方法可以封装这段代码而不引起这种重复?我开始使用 函数重载但使代码不必要地复杂化。


getManyAlbumsgetManyTracks 是对 API 执行请求的函数,可以模拟为:

function getManyAlbums(
  albums: Album[],
): Promise<(AlbumResponse | ErrorResponse)[]> {
  return Promise.resolve([
    {ok: false, message: 'error message'},
    {ok: true, data: {id: '1', name: 'bar', artist: 'foo'}},
  ])
}

function getManyTracks(
  tracks: Track[],
): Promise<(TrackResponse | ErrorResponse)[]> {
  return Promise.resolve([
    {ok: true, data: {id: '1', name: 'bar', artist: 'foo', isrc: 'baz'}},
    {ok: false, message: 'error message'},
  ])
}

这些是相关的类型声明:

type Track = {artist: string; song: string}
type Album = {artist: string; album: string}

type TrackResponse = {
  ok: true
  data: {id: string; name: string; artist: string; isrc: string}
}
type AlbumResponse = {
  ok: true
  data: {id: string; name: string; artist: string}
}

type ErrorResponse = {ok: false; message: string}

type NotFound = {total: number; data: unknown[]}
type GetReturnType<T> = {
  data: T
  report: {found: number; notFound: NotFound}
}

type GetTracks = GetReturnType<TrackResponse[]>
type GetAlbums = GetReturnType<AlbumResponse[]>
type GetTracksOrAlbums = GetTracks | GetAlbums

我认为如果您操作的数据结构尽可能相似,您会发现将通用功能抽象为单个函数会更容易。与其使用不同的 keys 来区分某事是与 Album 还是 Track 相关,不如使用不同的 values 来做到这一点。

例如,输入输出映射看起来像这样:

// Input types
interface TrackInput { artist: string; song: string }
interface AlbumInput { artist: string; album: string }

// Output Types
interface TrackOutput { id: string; name: string; artist: string; isrc: string }
interface AlbumOutput { id: string; name: string; artist: string }

interface DataIO {
  albums: { input: AlbumInput, output: AlbumOutput },
  tracks: { input: TrackInput, output: TrackOutput }
}

DataIO 类型只是为了让编译器跟踪哪个输入与哪个输出。如果我们可以仅根据这些类型来表示您的功能,那么我们就有机会使用 DataIO 来实现 generically.


我们可以这样定义输出:

interface Response<T> { ok: true, data: T }
interface ErrorResponse { ok: false; message: string }
type ResponseOrError<T> = (Response<T> | ErrorResponse)[]

对于输入,我们可以将原始的 {albums: AlbumInput[]}{tracks: TrackInput[]} 拆分为两个参数:一个 type 参数 "albums""tracks",以及AlbumInput[]TrackInput[]searchData 参数,具体取决于 type.

看起来像:

declare function getManyThings<K extends keyof DataIO>(
  type: K,
  searchData: Array<DataIO[K]['input']>
): Promise<ResponseOrError<DataIO[K]['output']>>;

这是一个通用函数,其中 Ktype 的类型,我们使用 indexed access types 将正确的类型赋予 searchData

根据现有功能实施该功能将需要一些 type assertions or the like, since the compiler can't really follow that the return value will actually match the declared return type. It can see that type is a particular value, but this doesn't have any effect on K. See microsoft/TypeScript#33014 来解决请求支持此类功能的问题。无论如何,它可能是这样的:

function getManyThings<K extends keyof DataIO>(
  type: K,
  searchData: Array<DataIO[K]['input']>
): Promise<ResponseOrError<DataIO[K]['output']>> {
  return type === "albums" ? 
    getManyAlbums(searchData as AlbumInput[]) : 
    getManyTracks(searchData as TrackInput[]);
}

然后你可以像这样写你的 filterTracksOrAlbums 而不会出现任何其他问题:

async function filterTracksOrAlbums<K extends keyof DataIO>(
  type: K,
  tracksOrAlbums: Array<DataIO[K]['input']>,
  notFound: NotFound,
) {
  const searchRes = await getManyThings(type, tracksOrAlbums);

  const result = searchRes.filter(
    (item, i): item is Response<DataIO[K]['output']> => {
      return filterErrors(() => {
        notFound.total++
        notFound.data.push(tracksOrAlbums[i])
      }, item)
    },
  )
  return result
}

注意,由于filterTracksOrAlbums()K中也是泛型的,所以要搜索的数据type,我们可以泛型表示过滤器代码的type predicate作为 item is Response<DataIO[K]['output']>.

Playground link to code