【TypeScript】和音(コード)の構成音を型推論できるユーティリティ型を作る
これは SmartHR アドベントカレンダー 2022 の 8 日目の記事です。
またこの記事の内容は、こちらのイベントに登壇した際のスライドをもとにしています。
はじめに
こんにちは、 SmartHR でフロントエンドエンジニアをしている ytaka です。
SmartHR では TypeScript を利用してフロントエンドの開発しています。
TypeScript、便利ですよね。
型によって安全に開発できますし、補完のおかげで素早くコードを書くこともできます。
そんなTypeScriptの便利な機能として、 ユーティリティ型 があります。ユーティリティ型を使うと、ある型から別の型を作れます。
たとえば、組み込みで用意されているユーティリティ型の一つに Pick があります。
Pick を使うと、オブジェクト型から任意のプロパティのみを抜き出したオブジェクト型を作成できます。
type User = {
name: string;
age: number;
createdAt: string;
updatedAt: string;
}
type UserInfo = Pick<User, 'name' | 'age'> // { name: string; age: number; }
またこのユーティリティ型は自作することもできます。
type AddTimeStamp<T> = T & { createdAt: string; updatedAt: string; }
// AddTimeStamp<{ name: string; age: number; }> => { name: string; age: number; createdAt: string; updatedAt: string; }
ここで出てきた T
は ジェネリクス と呼ばれ、これを使うことで型を変数のように扱うことができます。
この機能を使って、何か開発以外にも役立てられないかなと考えていました。
音楽理論について
さて、突然ですがみなさんは「音楽理論」という言葉を聞いたことがあるでしょうか?
音楽理論は、音楽の構造や手法を理論立てて説明するための集合知のようなものです。
音楽を構成する要素にはリズム、メロディー、ハーモニーなどがありますが、これらをでたらめに組み合わせただけでは心地よい音楽にはなりません。人間が心地よいと感じる音楽にはどんな仕組みがあるのか、音楽理論を知ることでそれを紐解くヒントを得ることができる、ようです。
偉そうに語っていますが、自分も音楽理論に詳しいわけでは決してありません。趣味でギターを弾いたりカラオケが好きだったりするので、勉強したいと漠然と思いつつも、なかなか手が出せずにいます。
音楽理論で使用される用語が多く、また難しい概念も多いため、一朝一夕に身に着けられるものではありません。
なにかいい方法はないものか、、、
💡
TypeScript の力を借りられるのでは??
やや強引な前振りでしたが、音楽理論の基礎的かつ重要な要素である 和音(コード) について、 TypeScript を用いて構成音を型推論することにチャレンジしてみました。
音名について
この記事中では、日本人にとっては慣れ親しんだ「ドレミファソラシド」という音の呼び方(イタリア語式)ではなく、音楽理論で一般的に使われる「CDEFGAB」という呼び方(ドイツ語式)を採用しています。
和音(コード)について
コードは3音以上の違う高さの音の重なりのことで、日本語だと和音と呼ばれます。
コードを構成する音はルート音とコードの種類によって決まります。コードの種類はたくさんありますが、以下の表にルート音とコードの種類の例を挙げています。
コード表記 | ルート音 | コードの種類 | 読み方 |
---|---|---|---|
C | C | メジャー | シーメジャー |
Cm | C | マイナー | シーマイナー |
C7 | C | セブンス | シーセブンス |
D | D | メジャー | ディーメジャー |
Dm | D | マイナー | ディーマイナー |
たとえば ルート音が C の メジャーコードは、 C, E, G の 3 つの音で構成されます。
Cメジャーコード
また ルート音が C の マイナーコード は、メジャーコードの真ん中の音が半音下がり、構成音は C, D#, G となります。
Cマイナーコード
ルート音とコードの種類の組み合わせは膨大でとても覚えきれないので、型推論できれば便利そうです!
コードのルール
コードの構成音を型推論するためには、まずはコードの音がどのように決まるかのルールを知る必要があります。
コードのルールを考えるために、わかりやすいように音に番号を振ってみます。
低い音から順に 0, 1, 2... と番号を振り、それをもとにコードの構成音を考えてみると、以下のようになります。
C メジャーコードと D メジャーコードの構成音
この図からわかるように、ルート音の異なる 2 つのメジャーコードの構成音は番号で表すと
C メジャーコードの構成音: [0, 4, 7]
D メジャーコードの構成音: [2, 6, 9]
また別の種類のコードについて、例えばマイナーコードでは以下のようになります。
C マイナーコードの構成音: [0, 3, 7]
D マイナーコードの構成音: [2, 5, 9]
ここから、
- ルート音によってコードの最初の音が決まる
- コードの種類によって、構成音の相対的な位置関係が決まる
と言えそうなことがわかります。
つまり、メジャーコードの構成音はルート音の音をあらわす数値を n
とすると、
[n, n+4, n+7]
と表すことができそうです。
またマイナーコードは
[n, n+3, n+7]
と表せますし、セブンスコードは
[n, n+4, n+7, n+11]
として表せます。
このルールをもとに、コードの構成音を推論するユーティリティ型を作成してみたいと思います。
STEP 0: ためしに JavaScript で実装してみる
いきなりユーティリティ型を実装するのはハードルが高いので、一度 JavaScript で素振りをしてみます。
まずは音名をオブジェクトで定義します。key は先ほど振った音をあらわす数字を設定し、 value はそれに対応する音名として定義します。
const noteDef = {
'0': 'C',
'1': 'C#',
'2': 'D',
'3': 'D#',
'4': 'E',
'5': 'F',
'6': 'F#',
'7': 'G',
'8': 'G#',
'9': 'A',
'10': 'A#',
'11': 'B'
}
この定義をもとに、ルート音をあらわす数値を受け取ってメジャーコードの構成音を返す関数は、ざっくり以下のように定義できそうです。
const majorCode = (n) => {
return [noteDef[n], noteDef[n + 4], noteDef[n + 7]];
}
この関数を用いると、C メジャーコードの構成音と D メジャーコードの構成音は、それぞれ以下のように得ることができます。
const cMajor = majorCode(0) // ['C', 'E', 'G']
const dMajor = majorCode(2) // ['D', 'F#', 'A']
JavaScript だと比較的あっさり実装できそうですね。TypeScript のユーティリティ型だとどうでしょうか?
以降は、JavaScript の実装を参考にしつつ、ユーティリティ型での実装を進めていきます。
STEP 1: TypeScript ユーティリティ型での実装
まず初めに、今回作りたいユーティリティ型は以下のようなものです。
type MajorCode = {/* なにかしらの実装 */}
type CMajor = MajorChord<0> // ["C", "E", "G"]
type DMajor = MajorChord<2> // ['D', 'F#', 'A']
この型の実現に必要な処理を、ユーティリティ型で定義していきます。
音の定義
音をあらわす数値を key, 音名を value にしたオブジェクト型として定義します。
音に関する定義はこのオブジェクトをもとに取得していきます。
type NoteDef = {
'0': 'C';
'1': 'C#';
'2': 'D';
'3': 'D#';
'4': 'E';
'5': 'F';
'6': 'F#';
'7': 'G';
'8': 'G#';
'9': 'A';
'10': 'A#';
'11': 'B';
}
この定義をもとに、音名を網羅した Note
という型を定義していきます。
そのために、まずオブジェクト型の value のユニオン型を取得できるユーティリティ型 ValueOf
を定義します。
このユーティリティ型は汎用性が高く、業務でも利用する場面は多いかと思います。
type ValueOf<T> = T[keyof T]
// ValueOf<{ key1: 'hoge'; key2: 'fuga'}> => 'hoge' | 'fuga'
先ほど定義した NoteDef
に対して ValueOf
を利用することで、音名のユニオン型 Note
を取得できます。
このユニオン型を使うことで、ある文字列が音名をあらわしているかどうかを判定できます。
type Note = ValueOf<NoteDef> // 'C' | 'C#' | 'D' |'D#' |'E' | 'F' | 'F#' | 'G' | 'G#' | 'A' | 'A#' | 'B'
最後に、数値型を受け取り、NoteDef の key に含まれている数値ならその key を返し、含まれていなければ never を返すユーティリティ型も定義しておきます。
このユーティリティ型を通すことで、その数値が NoteDef の key として存在していることを保証することができます。
type Num2NoteId<T extends number> = `${T}` extends keyof NoteDef ? `${T}` : never
// Num2NoteId<0> => '0'
// Num2NoteId<12> => never
型での足し算
数値の足し算は JavaScript(やその他プログラミング言語)では簡単に書ける一方で、TypeScriptではすんなりとはいきません。
以下のように書ければシンプルですが、これは構文エラーになります。
type One = 1
type Two = 2
type WannaBeThree = One + Two // 構文エラー
そこで、TypeScript の型で数値型同士の足し算を行うために タプル型 を利用します。
type SomeArray = [1, 2, 3, 4, 5]
type Five = SomeArray['length'] // 5
TypeScript のタプル型は length
というプロパティを通じて、タプルの要素数を数値型として取得することができます。
これとスプレッド演算子による展開を利用すると、 与えられた2つのタプル型の要素数の合計を返すユーティリティ型を作ることができます。
type SumArray<T extends any[], K extends any[]> = [...T, ...K]['length']
// SumArray<[1, 2], [1, 2, 3]> => 5
次に、数値を受け取り、それと同じ要素数をもつタプル型を返すユーティリティ型を定義してみます。
type Num2Array<T extends number, Result extends any[] = []> = Result['length'] extends T
? Result
: Num2Array<T, [...Result, any]>
// Num2Array<3> => [any, any, any]
このユーティリティ型では、三項演算子のような記法で書かれる Conditional Type を使い、条件を分岐させつつ処理を再帰させて必要な要素数になるまで要素を追加することで、目的の処理を実現しています。
参考
Recursive Conditional Types
以上を組み合わせて、数値型同士の足し算を実現するためのユーティリティ型を作ることができました。
type Sum<T extends number, K extends number> = [
...Num2Array<T>,
...Num2Array<K>
]['length']
// Sum<1, 2> => 3
数値型 => 文字列型への変換
テンプレートリテラルのような記法を用いて、新しい文字列リテラル型を生成することができます。
これを利用して、数値型を受け取り、文字列型に変換するユーティリティ型を定義しておきます。
type Num2String<T extends number> = `${T}`;
mod 計算
コードの構成音を取得するために ある音からNだけ離れた音
を取得したいですが、音の定義(NoteDef
)は1オクターブ分しか定義していないため、場合によっては定義した領域をはみ出てしまうことがあります。
実際には、例えば B(シ)より 1 つ高い音は次のオクターブの C(ド)となるように、オクターブごとに音は繰り返して並んでいるため、型の上でもそれをうまく表現したいです。
これは、mod
をとることで、定義内におさまるように処理することができるのではと考えました。
TypeScript の再帰の回数制限や、0 で mod をとる時の処理などを考慮し始めるとこの実装ではまずいですが、単純には以下のようにすることで mod 計算を実現できそうです。
type Mod<A extends number, B extends number> = Num2Array<A> extends [...Num2Array<B>, ...infer R]
? Mod<R['length'], B>
: A
// Mod<15, 12> => 3
infer は Conditional Type の中で使うことができ、与えられた条件に推論できる場合は infer R
の R
に推論された型情報を受け取ることができます(R はただの変数名なので、なんでもいいです)。
複雑に見える処理ですが、やりたいことは A が B より大きければ A から B を引いた値をもう一度 Mod に渡して処理を再帰させ、終了条件としてA が B より小さければ A を返す、という処理になっています。
参考
Type inference in conditional types
基準となる音から、N個となりの音名を返す
前半戦もいよいよ大詰です。ここまでで作成したユーティリティ型を組み合わせ、基準となる音から N 個離れた音を取得するユーティリティ型を定義します。
type NthNote<T extends number, N extends number> = NoteDef[Num2NoteId<
Mod<Sum<T, N>, 12>
>]
// NthNote<0, 2> => 'D'
コードの構成音を返す
以上のユーティリティ型を組み合わせて、今回の目的であるコードの構成音を返すユーティリティ型ができました!🎉
コードの種類に応じてユーティリティ型を作り分けます。
type MajorCode<T extends number> = [NthNote<T, 0>, NthNote<T, 4>, NthNote<T, 7>]
type MinorCode<T extends number> = [NthNote<T, 0>, NthNote<T, 3>, NthNote<T, 7>]
type SeventhCode<T extends number> = [NthNote<T, 0>, NthNote<T, 4>, NthNote<T, 7>, NthNote<T, 11>]
// Cメジャー: MajorCode<0> => ['C', 'E', 'G']
// Dメジャー: MajorCode<2> => ['D', 'F#', 'A']
STEP 2: 音名を受け取り、対応するコードの構成音を返したい
ここまででひとまず構成音を取得できるユーティリティ型を作成することができましたが、このユーティリティ型では、 音をあらわす数値型
を受け取っています。
できれば 直接音名を受け取って
推論できるとより使い勝手がよさそうです。
type CMajor = MajorCode<0> // これはわかりにくいので
type NewCMajor = NewMajorCode<'C'> // こうしたい
せっかくなので、こちらも実装してみましょう。
オブジェクトの反転
これまで作成したユーティリティ型を資源として活用するためには、音名の文字列型からその音をあらわす数値型を取得できればよさそうです。
NoteDef
をもとに、このオブジェクト型の key と value を反転したオブジェクト型を作ることで、逆に音名から数値を取得できそうです。
オブジェクトを反転するさせることができるユーティリティ型は、以下のように定義できます。
type Flip<T extends Record<string, string>> = { [P in keyof T as T[P]]: P }
// Flip<{hoge: 'fuga'}> => {fuga: 'hoge'}
TypeScript では {[A in B]: any}
の記法で、ユニオン型Bの各要素を key とするオブジェクト型をつくることができます。
このとき、 as
を組み合わせることで key に設定する値をよしなに書き換えることができます。ここでは、key に元オブジェクトの value を設定し、また value にはもとの key を設定することで、オブジェクトの反転を実現しています。
参考
Key Remapping in Mapped Types
文字列 => 数値型の変換
上で定義した反転オブジェクトを使うことで音名から音をあらわす数値を得ることができますが、この時点ではまだ数値を文字列型として取得してしまってます。
これを数値に変換するために、以下のようなユーティリティ型を定義します。
type String2Num<T extends string> = T extends `${infer K extends number}` ? K : never;
// String2Num<'10'> => 10
// String2Num<'ten'> => never
ポイントは extends number
で、これにより infer の推論をより厳密にできます。
ここでは、ある文字列が数値型として推論できるかどうかを確かめるのに利用しています。
参考
Improved Inference for infer Types in Template String Types
音名からの推論
以上を用いて、 NthNote
を以下のように書き換え、音名の文字列型を受け取れるようにします。
- type NthNote<T extends number, N extends number> = NoteDef[Num2NoteId<
- Mod<Sum<T, N>, 12>
- >]
+ type NthNote<T extends Note, N extends number> = NoteDef[Num2NoteId<
+ Mod<Sum<String2Num<Note2NoteId[T]>, N>, 12>
+ >];
これにより、音名を受け取って構成音の型を得ることができるようになりました🎉
type MajorCode<T extends Note> = [NthNote<T, 0>, NthNote<T, 4>, NthNote<T, 7>]
type MinorCode<T extends Note> = [NthNote<T, 0>, NthNote<T, 3>, NthNote<T, 7>]
type SeventhCode<T extends Note> = [NthNote<T, 0>, NthNote<T, 4>, NthNote<T, 7>, NthNote<T, 11>]
STEP 3: コード名から構成音を推論する
すでにだいぶ満足できる結果は得られていますが、最後にもう一工夫してみます。
これまではコードの種類ごとにユーティリティ型を定義し分けていましたが、利用者からするとこれは面倒です。
できれば、コード名をあらわす Cm
とか C7
とか C#m7
などといった文字列から、直接構成音を推論できると嬉しそうです。
これを実現するために、以下のようなユーティリティ型を定義しました。
type ChordParser<T extends string> = T extends Note
? MajorChord<T>
: T extends `${infer A}m`
? A extends Note
? MinorChord<A>
: []
: T extends `${infer A}m7`
? A extends Note
? MinorSeventhChord<A>
: []
: T extends `${infer A}M7`
? A extends Note
? MajorSeventhChord<A>
: []
: T extends `${infer A}7`
? A extends Note
? SeventhChord<A>
: []
: []
処理の内容は、文字列を受け取って愚直にパターンマッチし、対応する型を返しているだけです。
気力の問題で対応しているコードの種類は5種類(メジャー、マイナー、セブンス、メジャーセブンス、マイナーセブンス)しかありませんが、気力がわけばもっとたくさんのコードに対応することもできるはずです。
こうしてなんとか、コード名から構成音の型を得ることができました。
Cメジャーコード
C#メジャーセブンスコード
おわりに
試行錯誤の結果、コード名の文字列からその構成音を型で取得することができました。
これに挑戦してみる前までは知らなかった機能もたくさんあり、TypeScript の奥深さに触れることができました。
みなさまも良き TypeScript ライフと、良きミュージックライフをお過ごしください。
Discussion