お知らせ

現在サイトのリニューアル作業中のため、全体的にページの表示が乱れています。
投稿日:
言語::TypeScript開発::設計

TypeScriptで関数を書いているときに戻り値の型を書くケースがあるが、個人的にはあれは基本書かないほうがいいと思っているので、その理由を書いていく。

コード記述が冗長になる

まず型を書くと記述が冗長になる。以下のコードを見ると戻り値型が長く読みづらく、書くのも面倒だ。まずこんな長い命名をやめたほうが…というのはあれど、現実問題として長い命名は存在するので仕方ない。

export const getCampanyAndDepartmentAndEmployeeFromPrefectureCode = (
  param: VeryveryLoooongestParamTypeNaming,
): VeryveryLoooongestReturnTypeNaming => {
  return param.db.con.execQuery(
    "SELECT * FROM foo WHERE id = :?",
    param.dbParam.foo.id,
  );
};

しかし以下であれば戻り値型がない分すっきりしていて見やすいし、書く手間も掛からない。更に型推論によって正しい型が返るので理想的だ。

export const getCampanyAndDepartmentAndEmployeeFromPrefectureCode = (
  param: VeryveryLoooongestParamTypeNaming,
) => {
  return param.db.con.execQuery(
    "SELECT * FROM foo WHERE id = :?",
    param.dbParam.foo.id,
  );
};

実装と異なる戻り値型を暗黙的に記述できる

例えば以下のように書けば戻り値の型は'foo' | 'not foo'となり、常に正しい型が返る。

export const foo = (isFoo: boolean) => {
  return isFoo ? 'foo' : 'not foo';
};

しかし以下のように戻り値の型を定義すると実装上存在しない'bar'が返る。これは実装時に無用な混乱を生む。一般的にこのようなケースは仕様削除やリファクタなどで生まれることがあると思うが、そういうメンテ漏れにもなるので書かないほうが安全だといえる。

export const foo2 = (isFoo: boolean): 'foo' | 'not foo' | 'bar' => {
  return isFoo ? 'foo' : 'not foo';
};

他にも次のケースでは戻り値がstringとなり、何が返ってくるのかが実装を見ないと解らなくなる。特に理由がないなら書かないほうがよい。

// 'foo' | 'not foo'になるはずだがstring扱いになる
export const foo3 = (isFoo: boolean): string => {
  return isFoo ? 'foo' : 'not foo';
};

型記述が混乱する

ここは基本的に前項の内容と重複する内容となる。

例えば次の二つの実装は同じだが、戻り値の型だけが異なる。こういう実装が混在すると実装の一貫性が失われ無用な混乱を生むので、指定しないほうが望ましい。

const IndexPage = (): JSX.Element => {
  return (
    <Layout title={'Hello Next.js'}>
      <>
        <h1>Hello Next.js 👋</h1>
      </>
    </Layout>
  );
};

const IndexPage2 = (): ReactElement => {
  return (
    <Layout title={'Hello Next.js'}>
      <>
        <h1>Hello Next.js 👋</h1>
      </>
    </Layout>
  );
};

書いてもよいと思うケース

例えば依存関係を持たせたい時など、インターフェースとして型を共通化したい場合は書いてもよいと思う。これはどこでそれを使うのかが自明になるからだ。改修時にも型によって関連処理が見出しやすくなるので意識しやすくなる。

export const createPostMessage = (
  channel: string,
  username: string,
  message: string,
): PostMessage => {
  return {
    channel,
    username,
    message,
  };
};

export const postMessage = async (param: PostMessage) => {
  try {
    return await fetch('https://example.com/api/postMessage', param);
  } catch (err) {
    return err;
  }
};

但しこのようなケースではUnit Testを書いて、実装された戻り値型を満たす値が返ることを確認するのが望ましい。

戻り値型を書かないことによるデメリット

TypeScriptの公式リポジトリによると、型推論の速度に悪影響を及ぼすとあるので、型推論の速度が落ちるという点だ。

もし型推論の速度が非常に遅いと感じた場合は書いてみてもよいと思うが、公式でも以下のように案内があり、和訳すると「型推論は非常に便利なので、これを普遍的に行う必要はありませんが、コードの遅いセクションを特定した場合に試してみると便利です。」とあるので、余程複雑なことをしていない限り不要だとは思うし、そんな複雑な型を返すような処理は必要がなければ書かないほうがいいだろう。

Type inference is very convenient, so there's no need to do this universally - however, it can be a useful thing to try if you've identified a slow section of your code.

少なくとも私は実務上、型推論の速度に困ったことがないのと、tscでビルドすることも稀であるため、ビルドに影響することもない。よって基本書いていない。

ESM化が叫ばれて久しいですが、未だにJestはESMとCJSが混在したコードを処理してくれません。

Getting StartedにもSWCに対する言及がないので、きっともう忘れられているのでしょう。swc-project/jestの方も特にやる気はなさそうだし、やりたければ自分でPR書きましょうって感じだと思います。きっと。

確認環境

node_modules配下にESMで作られたモジュールが存在し、コードはTypeScript、トランスパイルにはSWCを利用する。

Env Ver
@swc/cli 0.1.62
@swc/core 1.3.92
@swc/jest 0.2.29
@swc/register 0.1.10
jest 29.7.0
typescript 5.2.2

やったけど意味がなかったこと

  • package.jsontypemoduleにする
  • jest.config.jstransformIgnorePatternsにESMモジュールのパスだけ除外する設定を書く
  • 上記に加えてtransform.jsc.pathpkg-name: ['node_modules/pkg-name']を追記する
  • node --experimental-vm-modules node_modules/jest/bin/jest.jsで実行する
    • 多少マシになったがコケるものはコケる
  • ESMで書かれたモジュールを丸ごとモックする
    • 一切効果なし

所感

多分Webpackでバンドルしてnode_modulesの中身も外も関係ない状態にするのが一番無難なのではないかと思いました。

Node.jsの組み込みテストランナーにすれば解決するかな?と思ったものの、こちらは現状SWCでは使えそうにないので諦めました。
参考までに以下のコマンドで走らさせられます。

node --require @swc/register --test ./src/**/*.spec.ts

取り敢えずESMに引っかったモジュールはCJS時のバージョンを維持しておくことにしましたが、このままだとSWC使えないし、なんとかなって欲しいですね。Webpack使えば解決できるのはわかるんですが、このために使いたくないので、テストを重視する場合、Vitestを持つViteが有力候補になって来そうです。

2024-02-17追記

esbuild + Node.js built-in test runnerの組み合わせであればテストはできるが肝心の実行ができず無意味だった

undefinedの判定方法が複数あるということでundefined判定の処理速度比較をしてみたのでその結果。

端的に言うと、hoge === undefinedtypeof hoge === 'undefined'の二方式がある。後者は原則考慮不要だが、言語仕様上存在しているので比較したが、現実的に見た場合、どちらで記述した場合でも処理速度に有意な差はないように感じた。

確認環境

Env Ver
Node.js 20.1.0
TypeScript 4.9.5
@swc/core 1.3.8

比較結果

hoge === undefinedの方が早く見えるが実行するタイミングで変わるので誤差の範疇だと思う。

方式 ms
hoge === undefined 4,514
typeof hoge === 'undefined' 4,515

確認コード

const tyof = (param?: string) => {
  return typeof param === 'undefined';
};

const undef = (param?: string) => {
  return param === undefined;
};

const tyStart = +new Date();
for (let i = 0; i < 10000000000; i++) {
  tyof();
}
console.log('typeof', +new Date() - tyStart);

const unStart = +new Date();
for (let i = 0; i < 10000000000; i++) {
  undef();
}
console.log('undefined', +new Date() - unStart);

TSから生成されたJS

"use strict";
Object.defineProperty(exports, "__esModule", {
    value: true
});
const tyof = (param)=>{
    return typeof param === 'undefined';
};
const undef = (param)=>{
    return param === undefined;
};
const tyStart = +new Date();
for(let i = 0; i < 10000000000; i++){
    tyof();
}
console.log('typeof', +new Date() - tyStart);
const unStart = +new Date();
for(let i = 0; i < 10000000000; i++){
    undef();
}
console.log('undefined', +new Date() - unStart);

あとがき

MDNを読む限りtypeof hoge === 'undefined'は該当変数が存在しない場合に有用なようであるが、TypeScriptで書いている場合、通常このようなコードが生まれることがなく、仮に起きるとした場合、次のようなコードになるため現実的に考慮する必要はない。なおMDNにも「こんなことはしないこと」と書いてあるので、一般的なコードでないことは客観的にも伺えるだろう。

(() => {
  const undefined = 123;
  const hoge = undefined;

  if (typeof hoge === 'undefined') {
    console.log('hoge is undefined');
  } else {
    console.log('hoge is not undefined');
  }
})();

上記コードの実行結果としてはhoge is not undefinedが出力される。

このコードの主な問題点

  1. const undefined = 123;というコードは予約語を変数名にしているため、混乱を招くコードであり、書かないことが好ましい
    1. MDNには予約語ではないとあるが、一般的には予約語の一つとして解釈して支障ないと考える
  2. このコードはESLintのeslint:recommendedで検知されるため、通常であれば書かれることはない
    1. no-shadow-restricted-namesに引っかかる

なお、このコードは例示のために即時実行関数形式で記述しているが、必要がない限りこの形式での実装は避けたほうが問題が少なくなると思う。これは不必要なネストが生まれたり、スコープの混乱を生むためである。

投稿日:
言語::C#開発::テスト

Windows向けのツールを作ろうというので、昔よく使っていたC#.NETで開発をすることにしたのですが結構苦労があったのでその話を書いていこうと思います。Unit testingを書くのは一旦断念しましたが、暇があれば挑戦したいとは思ってます。

事のいきさつ

WindowsのVSCodeでRemote Development拡張を使うことが多いのですが、リモートエクスプローラーにアイテムが増えてくると移動したいフォルダを探すのが大変になってくるのと、ここに出てないフォルダを開くのが結構手間という問題があり、これを解決できないかということを考えていました。

そこで考えついたのがWSLやSSHのネットワークパス上でExplorerの右クリックメニューを開いたらVSCodeがリモートモードで起動できると良いのでは?という所でした。Windows向けでexeからバイナリ起動するならC#で書くのが楽だろうと考え、C#での開発に着手しました。

疎結合な実装でUnit testingもできたらいいなーと漠然と考えながら開発していたのですが、最後にC#を書いたのは3年ほど前で、C#でモダンな実装をした経験もなかったので結構苦労しました。よく考えたらTypeScriptかJavaScriptくらいでしかまともにやったことがないし、UT自体他言語でもGolangかPHPでしか書いたことがなかったので、C#でこれをやるのは自分の実装技術に対する一種の挑戦みたいなところがありました。結論としてはあっけなく破れたわけですが…。

ツールの要件

  • Explorerの右クリックメニューから起動する
  • 開いたパスに応じてWindowsローカル、SSH、WSLを判定し、適切なモードでVSCodeを起動する
  • パス解決には設定ファイルを用いる
    • Explorerから渡されるパスはリモート環境のパスと差異があるので、そこを解決するためのものです

ツールの設計

やることは設定ファイルを読み込み、コマンドライン引数から値を取得し、それらをいい感じに変換してVSCodeに渡すだけです

大まかなフロー

大まかな動作フロー

実行形式

  • コマンドライン引数
    • this.exe <dir path>
  • 設定ファイル形式
    ```json5
    {
    "CodePath": "VSCodeのexeパス",
    // リモートホスト定義。辞書形式
    "Remote": {
    "リモートホスト名": {
    "ExplorerPrefix": "explorerのパスから除外する文字列",
    "AppendPrefix": "explorerのパスに追加する文字列"
    },
    }
    }
    ```

ツールの実装

まずはTDD的にやろうとしてInterfaceを作成し、Classで実装し、ロジック自体は好調に組み上がっていき、テストもすべて通るようになりました。但し実際にexeを蹴ると動かず、テストは通るが動かないゴミが出来上がっていました。(途中で動作確認をしなかったのか?と思うかもしれませんが、実際は完全に動くものを作ってからテスト可能な形に書き換えるという流れで作っていたのでこうなっています)

動かなかった理由は設定ファイルにJSONを採用していて、デシリアライザにSystem.Text.Json.JsonSerializerを使っていたためです。このデシリアライザは標準ではInterfaceに対してJSONをデシリアライズできません(考えてみれば当然ですが)。しかし、テストをするためにはモックをDIしたいので、Interfaceが必要です。

以下は実際に設定を読み込むために実装したClassですが、メンバが全部Interfaceになっているので、標準の状態ではデシリアライズに失敗します。ならばデシリアライザを自作すればいいという話が出てくるのですが、高々設定ファイルを読み込む処理にそこまで情熱を込めるか…?と言うことになり、諦めました。

public class ConfigBase : IConfigBase {
    private IFileInfo? _CodePath;
    private IDictionary<string, IConfigRemote>? _Remote;

    public IFileInfo CodePath {
        get {
            if (this._CodePath == null) {
                throw new Exception("Config Error: Missing CodePath.");
            } else if (this._CodePath == null) {
                throw new Exception("Config Error: CodePath is empty. Set the code.exe path in this field.");
            } else if (!this._CodePath.Exists) {
                throw new Exception("Config Error: CodePath is Not exists.");
            } else {
                return this._CodePath;
            }
        }
        set { this._CodePath = value; }
    }

    public IDictionary<string, IConfigRemote> Remote {
        get {
            if (this._Remote == null) {
                throw new Exception("Config Error: Missing Remote.");
            } else if (this._Remote.Count == 0) {
                throw new Exception("Config Error: Remote is empty. Set the remote infomation in this field.");
            } else {
                return this._Remote;
            }
        }
        set { this._Remote = value; }
    }
}

そもそもこのツールは「設定ファイルを読み込み、コマンドライン引数から値を取得し、それらをいい感じに変換してVSCodeに渡すだけ」のツールです。たったそれだけのツールに入れる仕組みにしては大げさすぎると感じました。

テストをするためにInterfaceを作ったり、本実装とモック用のClassを作る程度まではまだ容認できるのですが、カスタムデシリアライザを作ると、今度はそれのテストも必要になってきます。どう考えてもしんどい。

因みにこのコード、仮にInterfaceをやめても文字列をFileInfoに組み替えるためのカスタムデシリアライザの実装が必要で結構頭が痛くなります…。多分そこはFile.existsをラップしたクラスをDIしてやるのが無難な気がしますね。

この辺はTypeScriptだとimportの中身をJestで書き換えたら終わりなのであんま考えなくていいのは楽ですが、C#だと厳しいなと感じました。

C#でテストを書いていて思ったこと

クラスベースで実装していくとメソッド単位でのテストがしづらく、テストがコケても原因が把握しづらいというのを一つ課題感として覚えました。TSでなんちゃって関数型開発をしていれば関数のUTを書けば関数の挙動を把握できますが、Classではそうも行きません。

仮に1つのpublic methodが5つのprivate methodを呼び出していて、private methodの裏ではprivate propertyが複雑な依存を持っていたとしたらどうでしょうか?
正直デバッグをかけないとどこで何がコケたか特定できないと思います。そんな実装にするのが悪いといえばそうですが、でもクラスってそういうものじゃないですっけ…?お互いに関連性がなくていいならもうそれ関数で良くないですか?って思いました。

勿論、全部静的メソッドにしてClassそのものは単なるエンティティにするのも選択肢の一つだとは思います。しかしC#でそこまでやるか…?という疑念が個人的にあるのと、処理を繋げたテストをUTとして書く方法がなくなると思います。例えばインスタンスメソッドならモックをDIすることでメソッドが呼ばれた事や、戻り値に対する分岐を確認できますが、静的メソッドでこれをやるのは難しいと思います。

Classの単体テストは関数と状態が密結合したテストになってしまうので、いまいち微妙だなと思ったのが今回思ったことでした。

runasでは上手く出来ないっぽいのでPowerShellを呼んで実行する。管理者実行したいコマンドを分けたい時に活用できる。

確認環境

Env Ver
Windows 11 Pro 22621.1413

サンプルコード

powershell start-process <ここにコマンド> -verb runas

備考

PowerShellのリファレンスに沿って書くなら大文字小文字を書き分ける方がより正しいが、基本的に大文字と小文字を区別しないため、どっちでも動く。

powershell Start-Process <ここにコマンド> -Verb runAs