はじめに
route-cacheというライブラリにおいて、TypeScriptの型定義がなかった。そこでいつも通りDefinitelyTypedにPRを・・・と思ったが、このモジュール、Classではないがfunctionがthis
を返すような実装になっていた。具体的には以下のような実装になっていた。
module.exports.config = function (opts) {
if (opts) {
if (opts.max) {
defaults.max = opts.max
}
if (opts.cacheStore) {
cacheStore = opts.cacheStore
} else {
cacheStore = new LruStore(defaults)
}
module.exports.cacheStore = cacheStore
}
return this
}
ググってみても、メソッド戻り値のthis型とメソッドチェーンにあるような、Classの場合においては例があった。ただ、このような function でthis
を返す実装になっている場合に、どのような型定義をすればいいか?を考えた際の備忘録を今回は残そうと思う。
結論
interfaceを定義してメソッドチェーンが実装可能なようにする。具体的には以下。
import * as express from 'express';
export function config(options: ConfigOptions): RouteCache;
export function cacheSeconds(secondsTTL: number, cacheKey: string | CacheKeyFunc): express.RequestHandler;
export function removeCache(url: string): void;
export interface RouteCache {
config(options: ConfigOptions): RouteCache;
cacheSeconds(secondsTTL: number, cacheKey: string | CacheKeyFunc): express.RequestHandler;
removeCache(url: string): void;
cacheStore: Store;
}
export interface CacheKeyFunc {
(req: express.Request, res: express.Response): string | null;
}
export interface ConfigOptions {
max?: number;
cacheStore?: Store;
}
上記のような型定義にすることで、関数config
の戻りの型をinterface RouteCache
にし、そのinterfaceにメソッドチェーンできるべき関数を定義する、という実装をすることで例えば以下のような実装でもコンパイルエラーにならなくなる。
import routeCache from 'route-cache';
routeCache.config(configOptions).cacheSeconds(60, 'helloworld');
今回は上記のような型定義を行ったが、今思うと別の選択肢もあった気がしており、それについては次の章で取り上げる。
※今回実装した型定義の全体は以下になる。
※おまけに、試行錯誤してこれはダメか・・・となったものと、PR時の指摘を受けたことについて取り上げている。
今思うとこれでも同じようなことが実現できると思った定義方法
関数が複数エクスポートされているモジュールだったので、上記で見たような型定義で全然間違っていない(マージされたことを考えても)が、以下のような型定義もまた間違いではないと思われる。こちらの型定義の方がthis
を利用している分、メソッドチェーンでの利用がより分かりやすく感じられるかもしれない。
import * as express from 'express';
export = routeCache;
declare const routeCache: RouteCache;
interface RouteCache {
config(options: routeCache.ConfigOptions): this;
cacheSeconds(secondsTTL: number, cacheKey: string | routeCache.CacheKeyFunc): express.RequestHandler;
removeCache(url: string): void;
cacheStore: routeCache.Store;
}
declare namespace routeCache {
interface ConfigOptions {
max?: number;
cacheStore?: Store;
}
interface CacheKeyFunc {
(req: express.Request, res: express.Response): string | null;
}
interface Store {
get(key: string): Promise<any>;
set(key: string, value: any, ttlMillis: number): Promise<'OK'> | Promise<boolean>;
del(key: string): Promise<number> | Promise<void>;
}
}
上記でも問題なくコンパイルできる。
まとめとして
今回はTypeScriptの型定義において、function(関数)の戻り値がthis
であるような場合の型定義についてみてきた。Class等であればメソッドチェーンはあまり珍しくないが、関数モジュールがthisを返すパターンの型定義をあまり見たことがなかったが、今回実際にそのような型定義をしてみて理解を深められたと思う。
おまけ
return this
の型定義で検討したがNGだった定義方法
typeof import('route-cache');
のように自分自身のモジュールを指定する
これは以下のようにtypeof
で自分自身のモジュールを戻りの型に指定する方法。
export function config(options: ConfigOptions): typeof import('route-cache');
import('route-cache')
の部分は、import('./index')
のようにも実装できるが、結論から言うとこれはNG。理由はDefinitelyTypedのCIでエラーになるから。
具体的には、型定義のテストを以下のように実装することになるが、これは絶対パスになっており、これだとCIなどcheckoutされたディレクトリに依存してしまうので、自分の開発環境ではテストが通ってもCIでは通らないということが起きる。
import routeCache = require('route-cache');
routeCache.config({ max: 100 }); // $ExpectType typeof import("/home/study/workspace/DefinitelyTyped/types/route-cache/index")
※TypeScriptの型定義としてコンパイルエラーになるか?というとエラーにはならないので、定義方法としては誤りではないと思われる。が、DefinitelyTypedにPRを送るという前提ではNG。
PRで指摘を受けたことについて
前段の話
route-cache
というライブラリは、Expressのルートごとにそのレスポンスをキャッシュに保持し、TTLを迎えていなければそれを返すというものになっている。
このキャッシュをどこに保存するかで、デフォルトのlru-cacheのストアかioredisのストアの2つが選べるが、このストア自体はオプションして自由に設定できる仕様になっている。
が、TypeScriptで開発するときはJavaScriptの時のように、何でもかんでもが許容されるわけではなく、型で縛りを入れる必要が出てくる。そこで今回の型定義を作成する際には、以下のようにストアの型定義も合わせて作成し、それをexportすることを考えた。
export class Store {
get(key: string): Promise<any>;
set(key: string, value: any, ttlMillis: number): Promise<'OK'> | Promise<boolean>;
del(key: string): Promise<number> | Promise<void>;
}
こうすることで、ライブラリの利用者が独自に実装したストアの不備で、ランタイムエラーが出るというTypeScriptあるまじき状態を防げる。
本題
前段の話の通り、export class Store { ... }
というのを実装してPRを送ってみた。すると以下のような指摘を受けて修正を依頼された。
This shouldn't be exported as a class, because it implies there is a run time export called 'Store' that is a class value, when in fact there isn't. You can export it as an interface instead, or just keep the class without exporting it.
https://github.com/DefinitelyTyped/DefinitelyTyped/pull/66625#discussion_r1323325681
翻訳としては、
これはクラスとしてエクスポートすべきではありません。実際には存在しないのに、クラス値である 'Store' という実行時エクスポートがあることを意味するからです。代わりにインターフェースとしてエクスポートすることも、エクスポートせずにクラスをそのまま保持することもできます。
という内容になるが、つまりこれはどういうことかというと、以下のように実際には存在しないはずのStore
というクラスの値が存在することを型定義上意味する状態になり、元のJavaScriptの実装と大きく乖離してしまいうのでNGと言われていた(と理解している)。
なので、ストアの型定義を公開したい場合は、正しくはinterface
にして公開し、内部でストアの型定義をする場合はimplements Clausesに書かれている通り、interfaceをimplementsしてクラスを定義する方法を取る必要がある。
import * as LRUCache from 'lru-cache';
import { Store } from './index';
export = LruStore;
declare class LruStore implements Store {
...
}
型定義時に依存するライブラリがある場合の対応
DefinitelyTypedで型定義を実装する場合は、package.jsonの項に書かれている通り、package.jsonに依存するライブラリを設定する必要がある。
今回だと、ioredisとlru-cacheに依存した型定義を行っていたので、以下のようなpackage.jsonになった。型定義で利用するものなので基本的には@types/...
になるが、型定義がDefinitelyTypedではなく、各リポジトリで管理されている場合はそれを依存に加えることになるだろう。
{
"private": true,
"dependencies": {
"ioredis": "^5.3.2",
"@types/lru-cache": "^4.1.3"
}
}