🐕

実務で使えるTypescriptの型8選

2024/11/06に公開

ひとこと

実務に組み込みやすいようにできる限り実例を用いました。react前提です。TS初学者の方の助けになれば

解説

テンプレートリテラル型

型安全な文字列操作を実現

リテラル型で文字列を定義すれば渡した値がnumberであることを型で保証できる

const requestExternalApi = async ({
  data,
}: {
  data: {
    offset: `${number}`
    limit: `${number}`
  }
}) => {
  const res = await fetch("https://example.com", {
    method: "POST",
    body: JSON.stringify(data),
  })
  return res.json()
}

requestExternalApi({
  data: {
    offset: `${0}`, // `zero`を渡すと型エラーになる
    limit: `${10}`, // 同じく`ten`を渡すと型エラーになる
  },
})

ジェネリック

import { useState } from "react"

const Select = <T extends string,>({
  options,
  value,
  onChange,
}: {
  options: T[]
  value: T
  onChange: (value: T) => void
}) => {
  return (
    <select value={value} onChange={(e) => onChange(e.target.value as T)}>
      {options.map((option) => (
        <option key={option} value={option}>
          {option}
        </option>
      ))}
    </select>
  )
}

type Drink = "water" | "coffee"
const options: Drink[] = ["water", "coffee"]

const Component = () => {
  const [selectedOption, setSelectedOption] = useState<Drink>(options[0])

  return (
    <Select
      options={options}
      value={selectedOption}
      onChange={(val) => setSelectedOption(val)}
    />
  )
}

labelとvalueを分けるならこんな感じです。

import { useState } from "react"

const Select = <T extends string, U extends { label: string; value: T }>({
  options,
  value,
  onChange,
}: {
  options: U[]
  value: T
  onChange: (value: T) => void
}) => {
  return (
    <select value={value} onChange={(e) => onChange(e.target.value as T)}>
      {options.map((option) => (
        <option key={option.value} value={option.value}>
          {option.label}
        </option>
      ))}
    </select>
  )
}

type Drink = "water" | "coffee"
const userOptions: { value: Drink; label: string }[] = [
  { value: "water", label: "水" },
  { value: "coffee", label: "コーヒー" },
]

const Component = () => {
  const [selectedOption, setSelectedOption] = useState(userOptions[0].value)

  return (
    <Select
      options={userOptions}
      value={selectedOption}
      onChange={(val) => setSelectedOption(val)}
    />
  )
}

ユニオン型 (|) と交差型 (&)

type BasicInfo = {
  id: number
  name: string
}

type PersonalUser = BasicInfo & {
  type: "personal"
  age: number
}

type CompanyUser = BasicInfo & {
  type: "company"
  phone: string
}

type User = PersonalUser | CompanyUser

// ユーザーデータを取得し、混合した状態で返す関数
const fetchUsers = async (): Promise<User[]> => {
  const response = await fetch("https://api.example.com/users")
  if (!response.ok) {
    throw new Error("Failed to fetch users")
  }

  return (await response.json()) as User[]
}

const Component = async () => {
  const users = await fetchUsers()

  return (
    <div>
      {users.map((user) => (
        <div>
          <div>{user.name}</div>
          {user.type === "personal" && <div>{user.age}</div>}
          {user.type === "company" && <div>{user.phone}</div>}
        </div>
      ))}
    </div>
  )
}

Mapped Typesas

プロパティ名を柔軟に変換できる

// KebabCaseはtype-festからインポートすると仮定
import type { KebabCase } from "type-fest"

// 既存の型
type GameSearchState = {
  gameId: number
  nameQuery: string
  isSoldOut: boolean
}

type UrlTypes<T> = {
  [K in keyof T as KebabCase<K>]: string
}

type UrlTypeGameSearchState = UrlTypes<GameSearchState>

// 使用例
const search = (state: UrlTypeGameSearchState) => {
  const url = new URL("https://example.com/search");
  Object.entries(state).forEach(([key, value]) => {
    url.searchParams.set(key, value);
  });
  return (window.location.href = url.toString());
};

const Component = () => {
  const [state, setState] = useState<GameSearchState>({
    gameId: 1,
    nameQuery: "Alice",
    isSoldOut: true,
  });

  const handleSearch = () => {
    search({
      "game-id": state.gameId.toString(),
      "name-query": state.nameQuery,
      "is-sold-out": state.isSoldOut.toString(),
    });
  };

  // ...
};

条件付き型 (T extends U ? X : Y)

条件に応じた型の分岐。型パズルで頻出する

const extractNumFields = <T extends Record<string, string | number>>(
  response: T,
): { [K in keyof T as T[K] extends number ? K : never]: T[K] } => {
  // ...
}

const aaa = extractNumFields({ a: 1, b: "2" }) // => { a: number }

ReturnTypeParameters

関数の戻り値やパラメータ型を再定義無しで使える

const requestToExternalApi = async () => {
  const response = await fetch("https://api.example.com/users")
  if (!response.ok) {
    throw new Error("Failed to fetch users")
  }
  return response.json() as Promise<{
    id: number
    type: "user" // バージョンアップ時にフィールド名がvariantに変更されるかもしれない。
  }>
}

const convertFromExternalApi = (
  // ReturnTypeなので、バージョンアップ時にフィールド名が変更されても型については対応不要
  response: Awaited<ReturnType<typeof requestToExternalApi>>, // => { id: number; type: "user" }
) => {
  return {
    id: response.id,
    myType: response.type,
  }
}
import { useMemo } from "react"

const useCalculateTotal = (items: { price: number; quantity: number }[]) => {
  return items.reduce((total, item) => total + item.price * item.quantity, 0)
}

const Component = ({
  itemData1,
  itemData2,
}: {
  itemData1: { id: string; record: { price: number; quantity: number }[] }
  itemData2: { id: string; record: { price: number; quantity: number }[] }
}) => {
  const calculatedItems: Parameters<typeof useCalculateTotal>[0] =
    useMemo(() => {
      return [
        ...itemData1.record.map((item) => ({
          price: item.price,
          quantity: item.quantity,
        })),
        ...itemData2.record.map((item) => ({
          price: item.price,
          quantity: item.quantity,
        })),
      ]
    }, [itemData1.record])

  const total = useCalculateTotal(calculatedItems)

  return <div>合計金額: {total}</div>
}

as constsatisfies

as constとsatisfiesを使って、リテラル型を維持しつつもsatisfiesで型を満たすことを保証。

const localizeData_JP = {
  novel: {
    chapter: "チャプター",
    episode: "話",
  },
} as const satisfies {
  novel: {
    chapter: string
    episode: string
  }
}

const chapter = localizeData_JP.novel.chapter // => "チャプター"

再帰的

再帰的データ構造の解析は読みづらいことが多い

localizeの引数に.で繋がった文字列を渡す場合に間違った文字列の場合型エラーを発生させる

const localizeData_JP = {
  novel: {
    chapter: "チャプター",
    episode: "話",
  },
} as const satisfies {
  novel: {
    chapter: string
    episode: string
  }
}

type RecursiveObj = { [key: string]: string | RecursiveObj }

type UnionWithDot<
  Prefix extends string,
  Key,
> = `${Prefix}${Prefix extends "" ? "" : "."}${Key extends string ? Key : ""}`

type DotKeys<Obj extends RecursiveObj, Prefix extends string = ""> = {
  [Key in keyof Obj]: Obj[Key] extends object
    ? DotKeys<Obj[Key], UnionWithDot<Prefix, Key>>
    : UnionWithDot<Prefix, Key>
}[keyof Obj]

export type ExtractedDotKeys = DotKeys<typeof localizeData_JP>

const localize = (key: ExtractedDotKeys) => {
  const splittedKey = key.split(".")

  const stringOrObj = splittedKey.reduce<RecursiveObj | string>((obj, key) => {
    if (typeof obj === "string") return obj

    return obj[key]
  }, localizeData_JP)

  return stringOrObj
}

localize("novel.chapter") // novel.chapにすると型エラーになる

まとめ

型とは向き合い続けるしか。

フィシルコム

Discussion