LoginSignup
0
0

TypeScriptの型定義でメソッドチェーンを表現する functionがthisを返すパターンにおいて

Last updated at Posted at 2023-09-19

はじめに

route-cacheというライブラリにおいて、TypeScriptの型定義がなかった。そこでいつも通りDefinitelyTypedにPRを・・・と思ったが、このモジュール、Classではないがfunctionがthisを返すような実装になっていた。具体的には以下のような実装になっていた。

https://github.com/bradoyler/route-cache/blob/v0.5.0/index.js#L18
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を利用している分、メソッドチェーンでの利用がより分かりやすく感じられるかもしれない。

index.d.ts
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では通らないということが起きる。

route-cache-tests.ts
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と言われていた(と理解している)。
image.png

なので、ストアの型定義を公開したい場合は、正しくはinterfaceにして公開し、内部でストアの型定義をする場合はimplements Clausesに書かれている通り、interfaceをimplementsしてクラスを定義する方法を取る必要がある。

lruStore.d.ts
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ではなく、各リポジトリで管理されている場合はそれを依存に加えることになるだろう。

package.json
{
    "private": true,
    "dependencies": {
        "ioredis": "^5.3.2",
        "@types/lru-cache": "^4.1.3"
    }
}
0
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
0
0