STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

TypeScript v4.9からv5.5で追加された機能のおさらい

STORES 予約 エンジニアの水野です。STORES 予約 の店舗管理画面で利用しているTypeScriptをv4.8からv5.5にアップグレードしたので追加された主な機能をおさらいしようと思います。

satisfies (v4.9)

v4.9で実装されました。

型アノテーションのように型付けしつつ型推論も行う演算子です。 例を見てみましょう。

type Color = 'red' | 'green' | 'blue'
 
const pallet: Record<Color, string | number[]> = {
 red: [255, 0, 0],
 green: '#00ff00',
 blue: '#0000ff',
}
 
// pallet.red => string | number[]

pallet.redの型はstring | number[]となっています。これはRecordでアノテーションしている通りの正常な結果ではありますが、pallet.redには[255, 0, 0]という値を渡しているのでnumber[]で推論してほしいというのが正直な気持ちです。satisfiesを使うことでこれが実現できます。

type Color = 'red' | 'green' | 'blue'
 
const pallet = {
 red: [255, 0, 0],
 green: '#00ff00',
 blue: '#0000ff',
} satisfies Record<Color, string | number[]>

// pallet.red => number[]

satisfiesによってpallet.rednumber[]と推論されました。もちろん間違った型を渡した場合やプロパティに過不足があった場合は型エラーになります。この様に、型の制約と同時に推論を求める場合にsatisfiesは有用です。

またas constと併用することも可能です。

const Type Parameters (v5.0)

v5.0で実装されました。

型引数に対してas constと同様の推論を得られるようになります。例えばcolorをマージする関数があったとして戻り値の推論結果は以下のようになります。

const mergeColors = <T extends Record<string, string>>(
  colors: T
): T & { green: "#00ff00" } => {
  return {
    ...colors,
    green: "#00ff00",
  };
};

const colors = mergeColors({ red: "#ff0000" });

// colors.green => "#00ff00"
// colors.red => string

greenと異なり引数で与えたredstringと推論されています。redgreenと同様リテラル型で推論してほしい場合はas constを使う方法があります。

const colors = mergeColors({ red: "#ff0000" } as const);

// colors.green => "#00ff00"
// colors.red => "#ff0000"

望む結果は得られましたがmergeColorsを呼び出す度にas constが必要となり冗長です。const Type Parametersを使うことこのas constが不要になります。

// 型引数Tの前にconstを付与する
const mergeColors = <const T extends Record<string, string>>(
  colors: T
): T & { green: "#00ff00" } => {
  return {
    ...colors,
    green: "#00ff00",
  };
};

const colors = mergeColors({ red: "#ff0000" });

// colors.green => "#00ff00"
// colors.red => "#ff0000"

型引数Tの前にconstを付与しました。こうするとTas constしたときと同様の推論がされます。結果、colors.redはリテラル型で推論され、mergeColorsを呼び出す度に書いていたas constも不要になっています。

using keyword (v5.2)

v5.2で実装されました。

関数やメソッドのスコープを抜ける直前に任意の処理を実行可能にします。 DBと接続して処理の終わりにCloseするコードを例に見てみます。

const connectDB = () => {
  const db = new DB()
  return {
    db,
    [Symbol.asyncDispose]: async () => {
      await db.close();
     },
   }
 }

 const doSomething = async () => {
   await using conn = connectDB()
   conn.db.sql('SELECT ...')
   // ここで db.close()が実行される
 }

connectDBの戻り値のオブジェクトにSymbol.asyncDispose (またはSymbol.dispose)の名で定義した関数を含めます。そして connectDB関数をusing keywordで呼び出すことで親関数のスコープを抜ける直前にSymbol.asyncDispose関数の処理が走るようになります。

これがusing keywordを使わない方法だと以下の様に都度db.close()を書く必要があるかもしれません。

const doSomething = () => {
  let conn
  try {
    conn = connectDB()
    conn.db.sql('SELECT ....')
  } finally {
    conn.db.close() 
  }
}

必要だけど漏れやすい処理をカプセル化できる点がusing keywordの利点です。

唐突にでてきたSymbol.asyncDisposeですがこれはWell-known symbolと言われる組み込みのSymbolでこの他にも様々なSymbolがあります。

ref: https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Symbol#well-known_symbol

NoInfer (v5.4)

v5.4で実装されました。

Inferは推論するという意味なのでNoInferだとその反対の意味になります。Noinferを使うと「与えられた情報を推論の材料に使わないでくれ」という意思をコンパイラに伝えることができます。例を見てみましょう。

const createStreetLight = <C extends string>(colors: C[], defaultColor: C) => {
  // ...
}

createStreetLight(["red", "yellow", "green"], "blue");

// 型引数C => "red" | "yellow" | "green" | "blue"

createStreetLightの型引数Cは与えられた引数の値から"red" | "yellow" | "green" | "blue" と推論されています。正しい結果なので型エラーもありません。

ここで引数defaultColorの値は引数colorsに渡した値のいずれかに限るという制約を設けたいとします。これはNoInferを使うことで実現できます。

const createStreetLight = <C extends string>(colors: C[], defaultColor: NoInfer<C>) => {
  // ...
}

// これは型エラーになる
// Argument of type '"blue"' is not assignable to parameter of type '"red" | "green" | "yellow"'.
createStreetLight(["red", "yellow", "green"], "blue");

// 型引数C => "red" | "yellow" | "green"

defaultColorの型をNoInfer<C>としました。こうすることでdefaultColorに与えられた値を推論の材料に使うなとコンパイラに伝えています。結果、型引数C"red" | "yellow" | "green"と推論され第二引数に与えた"blue"で型エラーにすることができました。

推論の強化 (v5.5)

v5.5になって推論がより強化されました。

filter

Arrayfilterメソッドで推論されるようになったのは大きな利点です。これはTypeScriptが関数の内容から型述語を推論するようになったため可能となりました。

const arr = ['foo', 'bar', null].filter((v) => v !== null)

// v5.4まで arr => (string | null)[]
// v5.5から arr => string[]

しかしこの推論がされるには4つの条件があります。

  1. 関数の戻り値に明確な型アノテーションや型述語が定義されていないこと
  2. 関数の中でreturnされていること
  3. 関数が引数を変更しないこと
  4. 関数が引数を使ったブール式を返すこと

このため以下のような実装では推論が効かなくなります。

// 関数に明確な型述語が定義してある
const isNumber = (v: unknown): v is number => {
  return typeof v === 'number'
}
const arr = [1, 2, null].filter(isNumber)

// arr => (number | null)[]


// キャストしておりブール式を返していない
const arr = [1, 2, null].filter((v) => !!v)
const arr = [1, 2, null].filter(Boolean)

// arr => (number | null)[]

1の条件については、明確な型述語には関数が比較している型と矛盾があってもエラーにならないという問題があったのでその対応の結果だと思います。関数の戻り値の型は定義したい身としてこの仕様は少し悩ましいと感じますが、ワンライナーで書ければ型述語を定義しているのとほぼ同じなので一応の納得はできます。

// const isNumber: (v: unknown) => v is number
const isNumber = (v: unknown) => typeof v === 'number'

型ガード

filterで触れた通り、型述語を推論するようになったので明確な型述語無しで型ガードも効くようになりました。

// v5.4まで型述語が必要
const isNumber = (v: unknown): v is number => {
  return typeof v === 'number'
}

// v5.5から型述語がなくても型ガードが効く
const isNumber = (v: unknown) => typeof v === 'number'

let foo: string | number

if (isNumber(foo)) {
  // foo => number
}

正規表現チェック (v5.5)

v5.5で実装されました。

正規表現の誤りをTypeScriptが指摘してくれるようになりました。

// error: Range out of order in character class.
const regex = /[a-Z]/

// error: Numbers out of order in quantifier.
const regex = /a{2,1}/;

// error: There is nothing available for repetition
const regex = /^$*+?.()|{}[]/;

おわりに

以上、v4.9からv5.5で追加された主な機能のおさらいでした。こういった新機能は積極的に使って体に慣らしていかないと宝の持ち腐れになってしまいます。使ってみた所感などをチームで共有しながら開発に役立てて行きたいと思います。