はじめに
普段、camelcase-keysやsnakecase-keysなどを利用していたが、姉妹ライブラリとしてkebabcase-keysがあるのを知った。このライブラリではTypeScriptの型定義(index.d.ts)がなかった。そこで、
という記事を参考に、OSSに貢献!という事でkebabcase-keysの型定義を追加するPRを上げてマージされたので、その型定義の作業とそこで利用した記法などについて備忘録を残しておく。
マージされて公開されて型定義は以下。
kebabcase-keys
ライブラリの機能としては単純で、JSONのキーをlodash.kebabCaseを利用してkebab-caseに変換する、というもの。姉妹ライブラリにcamelcase-keysやsnakecase-keysがあり、以前、express-openapi-validatorのmiddlewareにカスタムでこのケース変換ライブラリを利用した事があった。
型定義を行う
camelcase-keysやsnakecase-keysの型定義を参考に、もっと楽して定義する事はできないか?という観点で今回は型定義を行っていった。
型定義を始める前の事前準備
DefinitelyTypedの手順に書かれている通り、まずはPRを上げる前に、自分のローカル環境で型が利用できるか?を確認する必要がある。そのためにはローカルの型定義を読み込ませる設定が必要になるので、まずはその事前準備を行う。
型定義をしたいライブラリを依存に追加する
まずは、型定義を追加したいNode.jsのライブラリを依存に追加する。今回はkebabcase-keysというライブラリの型定義を追加したいので、それを依存に追加する。
$ yarn add kebabcase-keys
tsconfig.jsonの設定を変更し、ローカルの型定義を参照できるようにする
tsconfig.jsonのtypeRootsの設定にローカルのパスを追加する。
{
...
"compilerOptions": {
...
"typeRoots": ["./srv/types", "./node_modules/@types"]
},
...
}
これで自動で参照されるnode_modules/@types
以外からも型定義を参照できるようになる。
最後に、./types/kebabcase-keys/index.d.ts
を作成して準備は完了になる(このindex.d.ts
が型定義のファイル)。
declare module 'kebabcase-keys' {}
※ちなみに、型定義がない状態でtsc
コマンドでコンパイルを行っても、以下のようにエラーになる。
Step1 Genericsで引数のJSONを元に型定義をする
まずは、Genericsを利用して引数に渡された値を元に、キーのケース変換を行い戻り値の型を定義する。定義としては以下のようにした。
declare module 'kebabcase-keys' {
import { JsonObject, KebabCase } from 'type-fest';
type Options = {
...
readonly deep?: boolean;
...
readonly exclude?: ReadonlyArray<string | RegExp>;
};
type KebabCasedProperties<T> = T extends readonly JsonObject[]
? {
[Key in keyof T]: KebabCasedProperties<T[Key]>;
}
: T extends JsonObject
? {
[Key in keyof T as KebabCase<Key>]: T[Key] extends JsonObject | JsonObject[]
? KebabCasedProperties<T[Key]>
: T[Key];
}
: T;
declare function kebabcaseKeys<T extends JsonObject | JsonObject[]>(
input: T,
options?: Options
): KebabCasedProperties<T>;
export = kebabcaseKeys;
}
上記のようにする事で、以下のようなコードを安全に実装できる。具体的には、以下の画像のようにJsonのキーがケバブケースに変換されて、VS Codeからは変換後のキー名でサジェストが出るようになる。
引数 | ||
---|---|---|
json | ||
json[] |
// 上記の画像の実装例
import kebabcaseKeys from 'kebabcase-keys';
const result = kebabcaseKeys({
foo_bar: 'baz',
nested: { fooBaz: 'bar', arrayKey: ['123'], arrayJson: [{ testTest: 'test' }] }
});
console.log(result['foo-bar']);
const result2 = kebabcaseKeys([
{ foo_bar: 'baz' },
{ nested: { fooBaz: 'bar', arrayKey: ['123'], arrayJson: [{ testTest: 'test' }] } }
]);
console.log(result2[0]?.['foo-bar']);
そして、引数の方も以下のように、JsonObject
かJsonObject[]
を要求するようになるので、文字列の配列などkebabcase-keys
の実行時にエラーになるものをあらかじめ受け付けないようにできる。
ここまでのコードは以下。
※ちなみに、snakecase-keysではT extends Record<string, any> | readonly any[]
で型を定義していたが、これだとArray<string>
が許容されるので、以下のようにコンパイルエラーにはならないが、実行時にエラーになってしまう。
// コンパイルエラーにはならないが、コンパイル後の実行時にエラーになる実装
import kebabcaseKeys from 'kebabcase-keys';
const result = kebabcaseKeys(['aa']); // ← コンパイルエラーにならず、コンパイル後のNode.jsの実行時にエラーになる
console.log(result);
$ node build/app.js
/home/study/workspace/ts-node-oidc/node_modules/kebabcase-keys/node_modules/map-obj/index.js:21
isSeen.set(object, options.target);
^
TypeError: Invalid value used as weak map key
at WeakMap.set (<anonymous>)
at mapObject (/home/study/workspace/ts-node-oidc/node_modules/kebabcase-keys/node_modules/map-obj/index.js:21:9)
at kebabCaseConvert (/home/study/workspace/ts-node-oidc/node_modules/kebabcase-keys/index.js:16:9)
at /home/study/workspace/ts-node-oidc/node_modules/kebabcase-keys/index.js:37:40
at Array.map (<anonymous>)
at module.exports (/home/study/workspace/ts-node-oidc/node_modules/kebabcase-keys/index.js:37:29)
at file:///home/study/workspace/ts-node-oidc/build/app.js:4:16
at ModuleJob.run (node:internal/modules/esm/module_job:193:25)
at async Promise.all (index 0)
at async ESMLoader.import (node:internal/modules/esm/loader:530:24)
※後に分かったが、type-festをDefinitelyTypedの依存に加えるのはNGであると過去の議論の中で決まったようで、type-festを利用せずにTypeScript convert generic object from snake to camel caseを参考に、テンプレートリテラルを利用した型定義に変更した(コード全体はここ)。
型定義の方法について
-
kebabcaseKeys<T extends JsonObject | JsonObject[]>
genericで第1引数に渡されるものが、JsonObject or JsonObject[] を継承している事が保障される。JsonObjectの定義についてはtype-festの定義を参照。
実装方法としては型引数に制約をつけるを利用している。 -
T extends readonly JsonObject[] ? A : B
これはConditional Typesで、TがJsonObject[]を継承する場合にはAを、そうでない場合はBを、という意味。 -
{ [Key in keyof T]: KebabCasedProperties<T[Key], Deep>; }
これはT extends readonly JsonObject[]
の条件に合致する時の型定義だが、実装としてはMapped types on tuples and arraysを利用している。もし、{ [Key in keyof T]: T[Key] }
の場合は、そのままtuplesの中身が型として定義される(以下の画像の通り)。
だが、T[Key]
をKebabCasedProperties<T[Key], Deep>
とKebabCasedPropertiesタイプに変更しているので、T extends CustomJsonObject
の三項演算子の?
以降の型定義が適用される。 -
[Key in keyof T as KebabCase<Key>]
keyof T
はKeyof Type Operatorで、オブジェクトのキーの組合わせを生成するが、それをMapped Typesと併用する事でオブジェクトTのキーをプロパティのキーにできる。そこにType Assertionsで、ある型(今回はKebabCase)にアサーションする(kebab-caseにしたものをプロパティのキーにする)、という事をしてTのオブジェクトのキーをkebab-caseにした新しい型を定義している。 -
T[Key] extends JsonObject | JsonObject[] ? A : B
JSONのvalueがJSONである事もあり得るので、再帰的に型をKebabCasedPropertiesに変更するような実装をしている。
※keyofは
Note that in this example, M is string | number — this is because JavaScript object keys are always coerced to a string, so obj[0] is always the same as obj["0"].
と書かれている通り、stringとは限らないので、以下のように分岐が必要になるので注意。
type KebabCase<S extends string | number | symbol> = S extends number
? `${S}`
: S extends symbol
? never
: S extends string
? AnyCaseToKebab<S>
: S;
- 参考:https://www.typescriptlang.org/docs/handbook/2/keyof-types.html#the-keyof-type-operator
- 参考:https://ymizushi.hateblo.jp/entry/2022/06/20/232918
- 参考:サバイバルTypeScript keyof型演算子
Step2 JSONのvalueにobject
を許容する
type-festのJsonObject
だとkey-valueのvalueにDateなどのオブジェクトを指定した場合、以下のようにコンパイルエラーになってしまう。
そこでtype-fest
の型を独自に拡張してobject
も許可するように型定義を変更する。型定義を以下のコミットのように修正する(コード全体はここを参照)。
上記のように修正する事で、Dateなどの任意のobject
をJSONのvalueに渡してもコンパイルエラーにならなくなる。
引数 | ||
---|---|---|
json | ||
json[] |
追記
今回はtype-festを参考に、JsonObjectとして型定義を行ったが、Record<string, unknown>
にする方法の方が良かったかもしれない。理由は、JsonValue
がユニオン型であるが、これがobject
を含むのでsort-type-constituentsのエラーになるため。
※Record<string, any>
とRecord<string, unknown>
の違いは、TypeScriptのRecordとRecordの振る舞い・挙動の違いを参照。
Step3
kebabcase-keysのJavaScriptコードの実装を見ると分かるが、第二引数にoptions
を渡す事ができ、そのoptionsは以下のような型で表現される。
type Options = {
readonly deep?: boolean;
readonly exclude?: Array<string | RegExp>;
};
それぞれ以下の機能を提供するためのオプションになる。
-
deep
ネストしたJSONにおいて、第2階層の以下の階層もケースの変換対象にするか?否か?の設定オプション。省略可能。省略した場合はfalse
扱い。 -
exclude
キーの中で、キー or 正規表現 の配列に合致するキーはケース変換の対象から除外する設定オプション。省略可能。省略した場合は[]
扱い。
これらのオプションに型定義も則るように型定義を修正してみたいと思う。
deepオプション
これはネストしたJSONに対してケース変換を適用するか?を設定するものなので、以下のように型定義を修正する事でdeep
がtrueの場合にのみ2階層以下にもケース変換を適用する、を表現できる(コード全体はここ)。
実際にdeep: true
の設定有無で、戻りの型定義が変化しているのが確認できる。
deep: true | deep: false |
---|---|
型定義の方法について
-
options?: OptionsType
省略可能なoptionsという引数を定義し、その型をinterface Options
に設定している(型引数でOptionsType extends Options
としているので)。 -
WithDefault<OptionsType['deep'], false>
とDeep extends boolean
WithDefault
はOptionsType['deep']
がundefinedの場合に、型引数の第2引数の型を返す型。つまり、optionsに何も設定しない場合は、boolean型リテラルの別のライブラリのPRを作成していて後から気づいたが、false
として扱われる。OptionsType['deep']
はboolean | undefined
またはunkonw
になるので、WithDefault
の実装はなしで、Options["deep"] extends boolean ? Options["deep"] : true
とすべきだった(一応、今の実装でもtrue
に設定にしない場合は、タプル型[true]
には合致しないので期待通りの型定義にはなっているが…)。 -
Deep[] extends Array<true> ? A : B
タイプパラメーターDeepのタプルがtrueのリテラル型のタプルを継承するか?、つまり、Deepタイプパラメーターがboolean型リテラルのtrueであるか?でyesならAを、noならBを、それぞれ型として適用するConditional Typesの実装。これにより、deep
オプションの有無でネストしたJSONに再帰的にKebabCasedProperties<T[Key], Deep>
を適用するか?を分岐できる。
excludeオプション
ケース変換から除外するキーを列挙するオプション。以下のように型定義を修正する事でexcludeに指定されたオプションに基づいてケース変換の適用有無を切り替える、を表現できる(コード全体はここ)。
実際にexclude: ['fooBar'] as const
の設定有無で、戻りの型定義が変化しているのが確認できる。
exclude未指定 | exclude: ['fooBar'] as const |
---|---|
※ただし、正規表現の場合は上記のような挙動にはならないので、(result as any).hoge
のような実装にならざる負えないだろう。
型定義の方法について
-
WithDefault<OptionsType['exclude'], []>
deepの時と同じで、OptionsType型からexcludeプロパティの型を取得し(as const
によりリテラルなタプル型になるが)、それがundefinedであればデフォルト値としてEmptyTuple([]
)にするという実装。 -
Array<Includes<Exclude, Key>> extends Array<true> ? Key : KebabCase<Key>
Includes
の実装はtype-festのIncludesそのもので、Includes
で利用しているIsEqual
はタプル型を利用して、AとBが一致するか?を判定し、boolean型リテラルのtrue or falseにしている。つまり、excludeで渡される配列リテラル(array literal)のタプル型の各要素がTのJSONのキーと一致するか?を判定。それが一致する=trueになればArray<Includes<Exclude, Key>>
がArray<true>
を継承している事になるので、三項演算子のKey
の型が適用されexcludeに列挙されたキーはkebab-caseにならない、が実現できる。 -
type IsEqual<A, B> = A[] extends B[] ? (B[] extends A[] ? true : false) : false;
タプル型を駆使してAとBが同じか?を判定している、タプル型のA([A])タプル型のB([B])がそれぞれ互いにextendsで継承しているという事は、部分集合とかではなく完全に一致している事が保障されるので、それで同じか?を判定している。 -
{ exclude: ['fooBar'] as const }
について
まず、OptionsType['exclude']
の意味だが、これはOptionsType型からexcludeプロパティの型を取得する記法。そのため、exclude: ['fooBar']
の場合は、OptionsType['exclude']
の型はstring[]
になる(以下の画像の通り)。
deep
オプションの場合は、true/falseがboolean型リテラルなのでそのままリテラルな型として渡されるので、引数の値がそのまま渡っているかの感覚になるが、あくまでOptionsType['deep']
はdeepプロパティの「型」を取得しているに過ぎない。
が、const assertionsを利用すると、exclude: ['fooBar'] as const
は以下のような型(readonlyのタプル型)になる。
このas const
(const assertion)が何者か?だが、リテラル型であればそのリテラル型を拡張しない(たとえば、"hello"から"string"に変更しない)といったアサーション機能で、今回のように配列リテラルの場合は読み取り専用のタプルになる。すなわち、型定義の中で扱われる時にOptionsType型からexcludeプロパティの型を取得する際に、['fooBar']はstring[]
ではなく、タプル型['fooBar']
として型取得されるので、タプルの要素に除外したいキー名が含まれるので、それを上記のIncludes<Exclude, Key>
で利用できるようになる。
・参考:How do I declare a read-only array tuple in TypeScript?
With const assertions, the compiler can be told to treat an array or an object as immutable, meaning that their properties are read-only. This also allows the creation of literal tuple types with narrower type inference (i.e. your ["a", "b"] can be of type ["a", "b"], rather than string[] without specifiying the whole thing as a contextual type)
PRを上げる
型定義をローカルの環境で作成で来たら、いよいよDefinitelyTypedにPRを作成する。全体の流れはHow can I contribute?に書かれている。
流れとしては以下。
- PRを作成する前に、自分のローカル環境で型定義を行い利用できるか?チェックする
→上記の手順でdone - (今回は新規に型定義を作成するので)Create a new packageにある方法でひな形を作成する
- 自動で作成されたテンプレートの
index.d.ts
を、1の手順で作成した型定義で更新する - prettierとdtslintを繰り返し、エラーがなくなるまで調整する
-
npm run test-all
でエラーが出ない事を確認 - PR作成、レビュー指摘を修正してマージされたら完了
以下で詳細を見ていく。
自動で作成されたテンプレートのindex.d.ts
を、1の手順で作成した型定義で更新する
以下のコマンドで型定義のひな型を作成できる。
$ npx dts-gen --dt --name kebabcase-keys --template module
あとは、上記の型定義を行うで作成した型定義をコピペすればいい。
prettierとdtslintを繰り返し、エラーがなくなるまで調整する
Prettierは公式に書かれている通りのコマンドを実行すればいい。
$ npm run prettier -- --write types/kebabcase-keys/**/*.ts
dtslintについては、以下のように型定義のテストを書いていけばいい。
import kebabcaseKeys = require('kebabcase-keys');
kebabcaseKeys({}); // $ExpectType {}
kebabcaseKeys({ foo_bar: true }); // $ExpectType { "foo-bar": true; }
kebabcaseKeys([]); // $ExpectType never[]
テストを実装したら、以下のコマンドでテストを実行しエラーが出なければOK。テストと言っても型を確認しているだけでJestやVitestのようなものではない。型定義が問題ない事を確認するためのものなので、できるだけ多くのパターンで型チェックのテストを書くのが良いみたい。
例えば型定義の期待値がずれていれば、以下のようにdtslintの方でエラーになる。
エラーになるテスト | エラーの内容 |
---|---|
npm run test-all
でエラーが出ない事を確認
PRを作成すると、CIでnpm run test-all
が実行されるので、ローカル環境でも同じテストを実行し、エラーがない事を確認する。
PR作成、レビュー指摘を修正してマージされたら完了
最後に、PRを作成してレビューの指摘があればそれを修正し、マージされれば完了になる。私のPRは2つあったが、それぞれ比較的直にレビューをしてもらえたので、PR作成から1~2日でマージとなった。