🎄

TypeScript初心者へ送る、もう一歩先のTypeScript

2024/12/16に公開

とりあえず型を書いている人へ

どうも、よだかです。
この記事はTSKaigi Advent Calendar 2024の16日目の記事です。
TypeScript初級者の方のために、自分が実際にコードを書いている時によく使うTypeScriptのテクニックをまとめてみました。参考になれば幸いです。

タグ付きユニオン

「タグ付きユニオン(Tagged Union)」は、「判別可能なユニオン(Discriminated Union)」と呼ばれることもあります。
簡単に言うと、タグとなるプロパティを定義したユニオン型を定義し、TypeScriptがそのタグの値を読んで、ユニオンで渡している型の中から適切な型を推論できるようにさせるということです。

使い方

以下の例では、typeというプロパティをタグに、判別可能なユニオン型を定義しています。

// 図形の型を定義
type Rectangle = { type: "rectangle"; width: number; height: number }; // 長方形
type Circle = { type: "circle"; radius: number } // 円
type Shape =
  | Circle
  | Rectangle

// 面積を計算する関数
function calculateArea(shape: Shape): number {
  switch (shape.type) {
    // タグがcircleなので
    case "circle":
      // shapeはCircleに推論される
      return Math.PI * shape.radius ** 2; // 円の面積
    // タグがrectangleなので
    case "rectangle":
      // shapeはRectangleに推論される
      return shape.width * shape.height; // 長方形の面積
    default:
      throw new Error("未知の図形タイプ");
  }
}

注意点

タグとして使えるのは、リテラル型('a',1,trueなど)とnullとundefinedです。
number型、string型などのリテラル型以外はタグに使えません。

ユーザー定義型ガード

ユーザー定義型ガード(User-Defined Type Guards)は、TypeScriptでカスタムの型の絞り込みを行うための便利な機能です。特定の条件を満たすかどうかをチェックする関数を定義し、それを型ガードとして利用できます。これにより、TypeScriptが型を正確に推論できるようになります。

基本的な仕組み

ユーザー定義型ガードは、関数の戻り値として型述語(param is Type)を使用します。これにより、TypeScriptに「この条件が満たされる場合、引数paramはType型である」と伝えることができます。

const isCircle = (shape: Shape): shape is Circle => {
  return shape.type === "circle";
}
if (isCircle(shape)) {
  // shapeはCircle型として扱われる
  console.log(shape.radius);
}

ちなみに僕はよく

const list = [null, 'aaa', undefined] as const
// const notEmptyList: "aaa"[]
const notEmptyList = list.filter((v): v is NonNullable<typeof v> => v != null)

としますが、最新のTypeScriptはこの必要はなく、notEmptyListは型ガードを使わなくてもnotEmptyListは"aaa"[]に推論されますので、最近は使う頻度は落ちてきています。

TypeScriptの型システムについて

閑話休題。次のテクニックで必要になるので、ここで一旦型システムの話をしましょう。
TypeScriptは「部分的構造型(Structural Typing)」を採用している型システムを持っています。これは、型がその「構造(プロパティやメソッド)」によって決定される仕組みで、他の「名前的型付け(Nominal Typing)」とは異なります。

部分的構造型とは?

部分的構造型では、あるオブジェクトが別の型と互換性を持つかどうかは、その型が要求するプロパティやメソッドをすべて持っているかどうかで判断されます。型の名前そのものや、特定の宣言で関連付けられた関係は考慮されません。

type Human = { name: string; id: number }
type Animal = { name: string; id: number }
const human: Human = { name: 'yodaka', id: 1 }
const animal: Animal = { name: 'cat', id: 2 }

const logAnimal = (animal: Animal) => {
  console.log(animal)
}
// HumanとAnimalの構造が同じなので、errorにならない
logAnimal(human)

基本的に僕は、JavaScriptに対する型付けを行うために存在するTypeScriptのこの型システムは非常に有用だと考えています。
ただ、上記のような振る舞いがシステム要件的に問題になる場合、少し工夫しなくてはなりません。
さあ話を戻しましょう。そこでBrandedTypeが出てきます。

Branded Type

Branded Type(ブランド型)は、ユニークな型を作るためのテクニックです。通常の型に「ブランド」を付けることで、値の型を区別するために使用されます。これは、単なる構造が同じ型を区別したい場合に特に有用です。

ブランド型の基本

ブランド型は、stringやnumberのような基本型に独自の「ブランド」を追加したものです。通常、TypeScriptでは構造的部分型(Structural Typing)を採用しているため、構造が同じであれば異なる型でも互換性があります。しかし、ブランド型を使うと、以下のように特定の型であることを保証できます。

// ブランド型を定義
type UserId = number & { __brand: "UserId" };
type OrderId = number & { __brand: "OrderId" };

// ブランド型を作成するヘルパー関数
function createUserId(id: number): UserId {
  return id as UserId; // 型アサーションでブランドを付与
}

function createOrderId(id: number): OrderId {
  return id as OrderId; // 型アサーションでブランドを付与
}

// 使用例
const userId: UserId = createUserId(1);
const orderId: OrderId = createOrderId(1001);

// 型安全性の例
function getUserById(id: UserId) {
  console.log(`ユーザーID: ${id}`);
}

getUserById(userId); // OK
getUserById(orderId); // エラー: 型 'OrderId' を 'UserId' に割り当てることはできません

タグ付きユニオンとの違い

ここまで読んでこう思った人がいるかと思います。「BrandedTypeはタグ付きユニオンに似ているな。」と。
BrandedTypeとタグ付きユニオンの一番の違いは「ランタイムに影響があるかどうか」です。
タグ付きユニオンのタグは前述した通り、リテラル型 or null or undefinedでないといけません。そのため、タグ自体は値としてランタイムにも存在する必要があります。
BrandedTypeは{ __brand: "OrderId" }のように、型定義に「ランタイムには存在しない型」を追加し、asでキャストすることで、TypeScriptのコンパイラにチェックさせるようにして値の違いを実現しています。
どっちがいいのかというと、それは僕にもわかりません。
実は僕がBrandedTypeを知ったのは、先日のTSKaigiKansai2024で、それまで僕はタグ付きユニオンしか使っていませんでした。
個人的にBrandedTypeは少しハックっぽい気もするので、ランタイムに影響があって困らなければ、タグ付きユニオンを使っていこうかなと思っています。

これなんて言うのってテクニック

これは名前を知らないのですが、よく使うテクニックです。
以下のようなコードを書くと、ageがなくなってしまいます。

const livingThing = {
  id: 1,
  name: 'piyo',
  age: 12
}

const doNothing = (param: { id: number, name: string }) => {
  return param
}

// const result: { id: number; name: string; }で推論され、ageが抜け落ちている
const result = doNothing(livingThing)

なので、以下のようにすると抜け落ちません。

const doNothing = <T extends { id: number, name: string }>(param: T) => {
  return param
}
// const result: { id: number; name: string; age: number; }
const result = doNothing(livingThing)

12/18追記

これの説明がちょい雑でいまいちわかりにくかったので、もう少し詳しく説明します。
まず、これを使わなければならない場合は関数がおそらくなんらかの副作用を起こしているということです。なので、可能なら副作用が起きないようなコードに書き換えることをおすすめします。
それでもライブラリの都合などで必要にせまられる場合があります。

たとえば、以下のようにバックエンドのスキーマをフロントと共有しているが、フロント側でだけ少しスキーマを変えたい場合。

// バックエンド
const schema = z.object({
  name: z.string(),
}).refine(() => ...)

// フロント側
// type error!!
schema.extends({
})

これはrefineの戻り値がz.ZodSchemaではないので、フロントではスキーマを変えられません。
なので、refineとスキーマを分離してフロントでスキーマを変更してからrefineを当てるという以下のようなロジックが考えられます。

// バックエンド
const schema = z.object({
  name: z.string(),
})
const refiner = (schema: z.ZodSchema) => schema.refine(() => ...))

// フロント側
let newSchema = schema.extends({
})
// なんのスキーマも持っていないany型が推論されてしまう
newSchema = refiner(newSchema)

しかし、これだとrefinerの型の戻り値はz.ZodSchemaをrefineしたものになるので、newSchemaの型からプロパティの情報が抜け落ちます。
この時にこのテクニックを使うと

// バックエンド
const schema = z.object({
  name: z.string(),
})
const refiner = <T extends z.ZodSchema>(schema: T) => schema.refine(() => ...))

// フロント側
let newSchema = schema.extends({
})
// nameのスキーマを持った型が推論される
newSchema = refiner(newSchema)

ということで、無事、型情報が抜け落ちることなく、型を書くことができました!

おわりに

最後までお読みいただきありがとうございました。
この記事を読んで、TypeScriptのこんな情報をもっと知りたい!TypeScripter達と語らいたい!と思った方は、僕がスタッフをさせていただいているTSKaigiというカンファレンスがありますので、ぜひご参加ください!
現在スポンサー募集が開始しております!
https://2025.tskaigi.org/

それではみなさん良いお年を!

Discussion