1
0

More than 1 year has passed since last update.

DefinitelyTypedに対して既存のライブラリの型定義をPRで送ってマージされた話

Last updated at Posted at 2023-06-25

はじめに

普段、camelcase-keyssnakecase-keysなどを利用していたが、姉妹ライブラリとしてkebabcase-keysがあるのを知った。このライブラリではTypeScriptの型定義(index.d.ts)がなかった。そこで、

という記事を参考に、OSSに貢献!という事でkebabcase-keysの型定義を追加するPRを上げてマージされたので、その型定義の作業とそこで利用した記法などについて備忘録を残しておく。

マージされて公開されて型定義は以下。

kebabcase-keys

ライブラリの機能としては単純で、JSONのキーをlodash.kebabCaseを利用してkebab-caseに変換する、というもの。姉妹ライブラリにcamelcase-keyssnakecase-keysがあり、以前、express-openapi-validatorのmiddlewareにカスタムでこのケース変換ライブラリを利用した事があった。

型定義を行う

camelcase-keyssnakecase-keysの型定義を参考に、もっと楽して定義する事はできないか?という観点で今回は型定義を行っていった。

型定義を始める前の事前準備

DefinitelyTypedの手順に書かれている通り、まずはPRを上げる前に、自分のローカル環境で型が利用できるか?を確認する必要がある。そのためにはローカルの型定義を読み込ませる設定が必要になるので、まずはその事前準備を行う。

型定義をしたいライブラリを依存に追加する

まずは、型定義を追加したいNode.jsのライブラリを依存に追加する。今回はkebabcase-keysというライブラリの型定義を追加したいので、それを依存に追加する。

$ yarn add kebabcase-keys

tsconfig.jsonの設定を変更し、ローカルの型定義を参照できるようにする

tsconfig.jsonのtypeRootsの設定にローカルのパスを追加する。

tsconfig.json
{
	...
	"compilerOptions": {
		...
		"typeRoots": ["./srv/types", "./node_modules/@types"]
	},
	...
}

これで自動で参照されるnode_modules/@types以外からも型定義を参照できるようになる。

最後に、./types/kebabcase-keys/index.d.ts を作成して準備は完了になる(このindex.d.tsが型定義のファイル)。

types/kebabcase-keys/index.d.ts
declare module 'kebabcase-keys' {}

※ちなみに、型定義がない状態でtscコマンドでコンパイルを行っても、以下のようにエラーになる。
image.png

Step1 Genericsで引数のJSONを元に型定義をする

まずは、Genericsを利用して引数に渡された値を元に、キーのケース変換を行い戻り値の型を定義する。定義としては以下のようにした。

srv/types/kebabcase-keys/index.d.ts
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 image.png image.png
json[] image.png image.png
srv/index.ts
// 上記の画像の実装例
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']);

そして、引数の方も以下のように、JsonObjectJsonObject[]を要求するようになるので、文字列の配列などkebabcase-keysの実行時にエラーになるものをあらかじめ受け付けないようにできる。
image.png

ここまでのコードは以下。

※ちなみに、snakecase-keysではT extends Record<string, any> | readonly any[]で型を定義していたが、これだとArray<string>が許容されるので、以下のようにコンパイルエラーにはならないが、実行時にエラーになってしまう。

srv/index.ts
// コンパイルエラーにはならないが、コンパイル後の実行時にエラーになる実装
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の中身が型として定義される(以下の画像の通り)。
    image.png
    image.png
    image.png
    だが、T[Key]KebabCasedProperties<T[Key], Deep>とKebabCasedPropertiesタイプに変更しているので、T extends CustomJsonObjectの三項演算子の?以降の型定義が適用される。

  • [Key in keyof T as KebabCase<Key>]
    keyof TKeyof 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;

Step2 JSONのvalueにobjectを許容する

type-festJsonObjectだとkey-valueのvalueにDateなどのオブジェクトを指定した場合、以下のようにコンパイルエラーになってしまう。
image.png

そこでtype-festの型を独自に拡張してobjectも許可するように型定義を変更する。型定義を以下のコミットのように修正する(コード全体はここを参照)。
image.png

上記のように修正する事で、Dateなどの任意のobjectをJSONのvalueに渡してもコンパイルエラーにならなくなる。

引数
json image.png image.png
json[] image.png image.png

追記

今回は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階層以下にもケース変換を適用する、を表現できる(コード全体はここ)。
image.png

実際にdeep: trueの設定有無で、戻りの型定義が変化しているのが確認できる。

deep: true deep: false
image.png image.png
型定義の方法について
  • options?: OptionsType
    省略可能なoptionsという引数を定義し、その型をinterface Optionsに設定している(型引数でOptionsType extends Optionsとしているので)。

  • WithDefault<OptionsType['deep'], false>Deep extends boolean
    WithDefaultOptionsType['deep']がundefinedの場合に、型引数の第2引数の型を返す型。つまり、optionsに何も設定しない場合は、boolean型リテラルのfalseとして扱われる。別のライブラリのPRを作成していて後から気づいたが、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に指定されたオプションに基づいてケース変換の適用有無を切り替える、を表現できる(コード全体はここ)。
image.png

実際にexclude: ['fooBar'] as constの設定有無で、戻りの型定義が変化しているのが確認できる。

exclude未指定 exclude: ['fooBar'] as const
image.png image.png

※ただし、正規表現の場合は上記のような挙動にはならないので、(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[]になる(以下の画像の通り)。
    image.png
    deepオプションの場合は、true/falseがboolean型リテラルなのでそのままリテラルな型として渡されるので、引数の値がそのまま渡っているかの感覚になるが、あくまでOptionsType['deep']はdeepプロパティの「型」を取得しているに過ぎない。
    が、const assertionsを利用すると、exclude: ['fooBar'] as constは以下のような型(readonlyのタプル型)になる。
    image.png
    この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?に書かれている。

流れとしては以下。

  1. PRを作成する前に、自分のローカル環境で型定義を行い利用できるか?チェックする
    →上記の手順でdone
  2. (今回は新規に型定義を作成するので)Create a new packageにある方法でひな形を作成する
  3. 自動で作成されたテンプレートのindex.d.tsを、1の手順で作成した型定義で更新する
  4. prettierとdtslintを繰り返し、エラーがなくなるまで調整する
  5. npm run test-allでエラーが出ない事を確認
  6. 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については、以下のように型定義のテストを書いていけばいい。

types/kebabcase-keys/kebabcase-keys-tests.ts
import kebabcaseKeys = require('kebabcase-keys');

kebabcaseKeys({}); // $ExpectType {}
kebabcaseKeys({ foo_bar: true }); // $ExpectType { "foo-bar": true; }

kebabcaseKeys([]); // $ExpectType never[]

テストを実装したら、以下のコマンドでテストを実行しエラーが出なければOK。テストと言っても型を確認しているだけでJestやVitestのようなものではない。型定義が問題ない事を確認するためのものなので、できるだけ多くのパターンで型チェックのテストを書くのが良いみたい。
image.png

例えば型定義の期待値がずれていれば、以下のようにdtslintの方でエラーになる。

エラーになるテスト エラーの内容
image.png image.png

npm run test-allでエラーが出ない事を確認

PRを作成すると、CIでnpm run test-allが実行されるので、ローカル環境でも同じテストを実行し、エラーがない事を確認する。

PR作成、レビュー指摘を修正してマージされたら完了

最後に、PRを作成してレビューの指摘があればそれを修正し、マージされれば完了になる。私のPRは2つあったが、それぞれ比較的直にレビューをしてもらえたので、PR作成から1~2日でマージとなった。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0