LoginSignup
90
44

More than 3 years have passed since last update.

TypeScriptコンパイラ入門 (1) エラーメッセージを改善しよう

Last updated at Posted at 2019-06-01

皆さん、TypeScript使ってますか? TypeScriptはオープンソースで開発されているプログラミング言語であり、JavaScriptプログラミングに静的型による安全性をもたらしてくれます……という説明は、おそらくこの記事を開いたみなさんには今さらでしょう。

TypeScriptはOSSであり、我々ユーザーがPull Requestを送って開発に貢献することができます。筆者はこの前のゴールデンウィークに初めてのPRをTypeScriptに送って数時間くらいでマージされました。これは先日リリースされたTypeScript 3.5に含まれています。

このとき初めてTypeScriptコンパイラのソースコードをじっくり読んでおり、それが筆者が最近書いた別記事TypeScriptの型推論詳説の執筆にも役立っています。

皆さんにもぜひTypeScriptへの貢献をおすすめしたく、その手引としてTypeScriptコンパイラ入門を書くこととしました。第1回では、恐らく最もとっつきやすい部分であるエラーメッセージの改善について解説します。今回筆者が送ったPRもエラーメッセージの改善です。(2)以降は筆者が次のPRを送ったら書きます。

この記事では筆者がどのような改善を行ったのか紹介し、修正を完了するまでの思考ステップを全て解説します。なお、記事中ではTypeScriptコンパイラのソースコードを必要に応じて掲載しながら解説します。ソースコードの著作権表記は記事末尾を参照してください。

改善の内容

今回筆者は次のissueをクローズしました。

これは、async関数の中以外の場所でawaitを使ったときのエラーメッセージに関するissueです。例として次のコードを見てください。このコードはasyncではない普通の関数の中でawaitを使っています。

test.ts
function foo() {
  await null;
}

これに対する従来のエラーメッセージ(TypeScript 3.4以前)は以下のスクリーンショットのように「'await'式は、非同期関数内でのみ使用できます。」とだけ表示していました。

Screenshot from Gyazo

今回のPRによって、TypeScript 3.5では次のスクリーンショットのようにメッセージが追加されています。

Screenshot from Gyazo

すなわち、従来のメッセージに加えて「Did you mean to mark this function as 'async'?」というメッセージが追加されています(これは新しいメッセージなのでまだ翻訳されていないようです。いつ翻訳されるのかはよく分かりませんが)。さらに、このメッセージはtest.ts(1, 10)という位置を示しています。この位置はfunction foo() {fooの部分に相当します。

つまるところ、awaitを含んでいる関数がどこなのかを示すメッセージを従来のエラーメッセージに加えて表示したということです。関数が大きいときに便利かもしれませんね。

このissueはgood first issueラベルが付いているissueの中から目ぼしい物を選びました。ただ、TypeScriptへの貢献を狙っている人は意外と多く、そのようなissueには大抵未経験っぽい人が「やります😆」的なコメントを付けています。誰にも取られずに残っているgood first issueは滅多にありません。

それにも関わらず残っているgood first issueというのはいくつかのパターンに分かれます。だいたいは次の3つです。

  1. やりますと言った人がそのまま音沙汰が無くなって放置されている。
  2. やりますと言った人が「やり方教えて😉」と言って無視された状態で放置されている。
  3. ちゃんとPRが出たがPRが放置されている。

3はだめですが、1と2の場合は残念ながら彼らにはまだ早かったのでしょう。数カ月も放置されていれば奪って大丈夫です。筆者が取ったissueも過去2人の人が挑戦して撃沈していました。

開発環境

TypeScriptへの貢献に際しては開発環境を整える必要があります。詳しくはCONTRIBUTING.mdに書いてあるのでそちらを参照しましょう。

あとでまた説明しますが、とりあえずソースを変更したらgulpを実行すれば(gulpをグローバルインストールしていない場合はnpx gulpでOK)ビルドされます。ビルドするとbuilt/local/tsc.jsという場所にソースからビルドされたtscができるので、これを使って動作を確かめられます。

TypeScriptコンパイラはそれ自身がTypeScriptで書かれています。いわゆるセルフホスティングというやつですね。TypeScriptということで筆者はVSCodeを使ってコードを編集しようとしましたが、コードベースが大きいためメモリがたった8GBのPCでは重くて使い物にならず、結局vimを使いました。

では、いよいよ修正に入っていきましょう。

エラーメッセージはどこで定義されているのか

今回はエラーメッセージに関する変更ということで、そもそもTypeScriptがどのようにエラーメッセージを定義しているのかを理解しておく必要があります。筆者は事前にいくつかのPRを見てエラーメッセージがどこで定義されているのか理解しました。

答えを言ってしまうと、src/compiler/diagnosticMessages.jsonに定義されています。ここに全てのエラーメッセージが定義されています。例えば上で例示したエラーメッセージは次のように書かれています。

    "'await' expression is only allowed within an async function.": {
        "category": "Error",
        "code": 1308
    },

このように、エラーメッセージをキーとしてcategorycodeの情報が書かれています。エラーコードについてはwikiで説明されています。このエラーメッセージはawaitasync関数の外では使えないという文法的な制約なので1000番台が割り当てられます。

今回のPRではDid you mean to mark this function as 'async'?という新しいメッセージを追加するので、このファイルに新しいメッセージを追加しました。

    "Did you mean to mark this function as 'async'?": {
        "category": "Error",
        "code": 1356
    },

元のエラーメッセージが1000番台(構文に関するエラーメッセージ)なので、同じく1000番台の一番最後にこのメッセージを追加します。

これでPRはもうできたも同然ですね。あとはこのエラーメッセージを表示する処理を追加するコードを書けば完成です。

なお、diagnosticMessages.jsonを変更したらgulp generate-diagnosticsを実行しておきましょう。実はdiagnosticMessages.jsonからはTypeScriptの型定義などのコードが自動生成されており、これを実行しておかないとコンパイラのソースコード中から当該エラーメッセージを利用できません。

変更すべき箇所を探す

ではいよいよコンパイラ本体を変更していきます。しかし、どこを編集すればいいのでしょうか。

まず前提として、TypeScriptの型検査をはじめとするメインの処理(構文解析をして抽象構文木 (AST) 1ができた状態でそれに対してエラーチェックを行う部分)はsrc/compiler/checker.tsに書かれています。よって、今回いじるのはこのファイルです。厳密にはawaitのチェックは型検査とかではなく構文上のエラーなのですが、構文解析の時点でこれをハンドルするのは面倒なので、構文解析の段階では一旦受け入れて抽象構文木を作り、その後でエラーを出すという戦略になっています。

型検査などを全部このファイル1つに詰め込んでいるため3万行くらいあってとてもでかいですが、全部を把握する必要はありません。今回はエラーメッセージを変えることになるため、まず当該のエラーメッセージを出している箇所を探しました。

ちょっと検索すると、以下のcheckAwaitExpression関数でこのエラーメッセージが使われていることが分かります(ソースコードはPR反映前のコミットe8161efのもの)。

        function checkAwaitExpression(node: AwaitExpression): Type {
            // Grammar checking
            if (produceDiagnostics) {
                if (!(node.flags & NodeFlags.AwaitContext)) {
                    grammarErrorOnFirstToken(node, Diagnostics.await_expression_is_only_allowed_within_an_async_function);
                }

                if (isInParameterInitializerBeforeContainingFunction(node)) {
                    error(node, Diagnostics.await_expressions_cannot_be_used_in_a_parameter_initializer);
                }
            }

            const operandType = checkExpression(node.expression);
            return checkAwaitedType(operandType, node, Diagnostics.Type_of_await_operand_must_either_be_a_valid_promise_or_must_not_contain_a_callable_then_member);
        }

checkAwaitExpressionという関数名から察するに、これはawait式に対する型チェックなどを行う関数でしょう。返り値がTypeですから、await式の型推論の結果が返されると思われます。前述のように、そのついでにawaitが使用可能な場所かどうかのチェックを行っています。

引数nodeの型がAwaitExpressionとなっていることから、これはawait exprという式を表すASTのノードであることが分かります。最初の// Grammar checkingというコメントがついているif文の中が、awaitが正しい場所で使われているかどうかのチェックのようですね。

当該のエラーメッセージは次の文で使われています。

grammarErrorOnFirstToken(node, Diagnostics.await_expression_is_only_allowed_within_an_async_function);

引数のDiagnostics.await_expression_is_only_allowed_within_an_async_functionという定数っぽいものがエラーメッセージです。このDiagnosticsというオブジェクトは前述のdiagnosticMessages.jsonから自動でコード生成されたものです。元々のDid you mean to mark this function as 'async'?というメッセージ(プロパティ名)から空白を_に変換したり記号を取り除いたりしたものがエラーメッセージを表すプロパティ名となります。

grammarErrorOnFirstTokenというのは、名前から察するにnodeの位置(FirstTokenとあるので最初のトークン、すなわちawaitの部分でしょう)で第2引数で指定したメッセージを発生させるという処理をする関数でしょう。これは先ほどお見せしたスクリーンショットと合致していますね。

Screenshot from Gyazo

ちなみに、エラーの発生条件である!(node.flags & NodeFlags.AwaitContext)が「await式がasync関数内にない」という条件を表していることになります。node.flagsは多分構文解析器がASTを生成するときに付与する情報でしょう。構文解析の時点で「そのノードの文脈がawait使用可能な文脈化どうか」という情報が付加されていることになります。まあ自然ですね。

なお、この部分を発見する方法ですが、await_expression_isとかでソースコード内をすぐ検索するとすぐに見つかりました。'await' expression is only allowed within an async function.というエラーメッセージがDiagnostics.await_expression_is_only_allowed_within_an_async_functionという名前でソースコード内に現れることを知っていれば見つけるのは簡単です。

もちろん、これを違うエラーに変更したりするとちゃんと反映されます。まずTypeScriptのソースコードをいじってちょっと挙動を壊してみるのも面白いですね。変更が反映済みのtscは、gulpでビルドしたあとbuilt/local/tsc.jsに存在しています。これを使ってTypeScriptファイルをコンパイルすることで挙動を確かめましょう。

どう変更するか検討する

では、変更すべき箇所が分かったので次はどう変更すればいいのか検討しましょう。

今回の目的は「既存のエラーメッセージにサブメッセージとしてDid you mean to mark this function as 'async'?を追加する」ということですから、エラー生成部分に手を入れる必要があります。まあ、さすがにサブメッセージ的な機構は既にあるはずです。そうでないとさすがにgood first issueではありません。

ということで、まずgrammarErrorOnFirstTokenの中身を見てみる必要があります。

        function grammarErrorOnFirstToken(node: Node, message: DiagnosticMessage, arg0?: any, arg1?: any, arg2?: any): boolean {
            const sourceFile = getSourceFileOfNode(node);
            if (!hasParseDiagnostics(sourceFile)) {
                const span = getSpanOfTokenAtPosition(sourceFile, node.pos);
                diagnostics.add(createFileDiagnostic(sourceFile, span.start, span.length, message, arg0, arg1, arg2));
                return true;
            }
            return false;
        }

今回使われている2つの引数の他に3つほどオプショナルな引数がありますが、これはプレースホルダーを持つエラーメッセージ用であることはまあ分かりますね。例えばCannot find name '{0}'.のようなエラーメッセージが該当します。

hasParseDiagnostics(sourceFile)は、関数名から察するに「このファイルで既に構文エラーのメッセージが発生しているか」を判定していると思われます。すでに発生している場合は追加でエラーを発生させるのを避けているわけですね。一般に、パースエラーが1度発生した時点で正常にソースを読み込むのは不可能でありいくらでも構文エラーが発生しますから、複数の構文エラーメッセージを出す意味がそんなに無いわけです。

その中でやっていることは、createFileDiagnostic関数でエラーメッセージを表すオブジェクトを作成し、それをdiagnostics.addで発生したエラーメッセージの一覧に追加しているように見えます。spanというのはエラーの範囲(画像の赤線が引かれている部分)を表すオブジェクトです。今回はgetSpanOfTokenAtPositionという関数名から察するに、一つのトークン(今回はawait式の最初のトークンなのでawaitキーワードでしょう)に相当する範囲を表すオブジェクトとなります。

今回のミッションはcreateFileDiagnosticで作られたエラーメッセージ(型を見るとDiagnosticWithLocation型のオブジェクトのようです)に対して、付加的な情報、すなわち“サブメッセージ"のようなものを追加することです。ということで、このDiagnosticWithLocationオブジェクトをいじる必要があります。どういじればいいのか調べるために、DiagnosticWithLocation型の定義を見てみましょう。

    export interface DiagnosticWithLocation extends Diagnostic {
        file: SourceFile;
        start: number;
        length: number;
    }

なるほど、これはDiagnosticオブジェクトにファイル中の位置を指し示すプロパティを追加したオブジェクトですね。名前から察するに、このDiagnosticがメインの型のようです。

    export interface Diagnostic extends DiagnosticRelatedInformation {
        /** May store more in future. For now, this will simply be `true` to indicate when a diagnostic is an unused-identifier diagnostic. */
        reportsUnnecessary?: {};
        source?: string;
        relatedInformation?: DiagnosticRelatedInformation[];
    }
    export interface DiagnosticRelatedInformation {
        category: DiagnosticCategory;
        code: number;
        file: SourceFile | undefined;
        start: number | undefined;
        length: number | undefined;
        messageText: string | DiagnosticMessageChain;
    }

codeなどのいかにもそれっぽい情報がありますが、目に付くのはrelatedInformationプロパティです。これはDiagnosticRelatedInformation[]型を持ち、あるDiagnostic型に対して0個以上のDiagnosticRElatedInformationオブジェクトを紐付けられるようになっています。これはまさに「エラーメッセージが子メッセージを持つ」という状況を表しており、今回の目的に激しく合致しています。

ということで、ここまでのコードリーディングでやるべきことがはっきりしました。Did you mean to mark this function as 'async'?というメッセージを表すDiagnosticRelatedInformationオブジェクトを作成し、それを既存のエラーメッセージのrelatedInformationに追加してやればいいのです。

どう実装するか検討する

何をすればいいかは分かりましたので、それをどう実装するかを次に考えます。やりたいことの通りにコードを書けばそれでいいのですが、あまりに汚い、というか既存のコードと合致しないコードは書きたくありません。特に自分は、今回やりたいことをなるべく簡単にやってくれる既存の関数があるのかどうか気になりました。

ということで、まずやったことは「ソースコードをrelatedInformationで検索する」ということです。relatedInformationを扱う便利関数を探すわけですね。

そうすると次のaddRelatedInfo関数が随所で呼ばれていることが分かります。まあ見れば分かる通り、与えられたDiagnosticオブジェクトのrelatedInformationDiagnosticRelatedInformationオブジェクトたちを追加してくれる便利関数ですね。

    export function addRelatedInfo<T extends Diagnostic>(diagnostic: T, ...relatedInformation: DiagnosticRelatedInformation[]): T {
        if (!diagnostic.relatedInformation) {
            diagnostic.relatedInformation = [];
        }
        diagnostic.relatedInformation.push(...relatedInformation);
        return diagnostic;
    }

これがあちこちで呼ばれているということは、これをさらにラップしてくれるハイレベルな(grammarErrorOnFirstTokenのような)便利関数は無さそうということが分かります。

これにより、実装方針が固まりました。具体的には「新しいエラーメッセージを表すDiagnosticオブジェクトを作り、addRelatedInfoを使って既存の処理により作られたDiagnosticオブジェクトにそれを追加し、最後にdiagnostics.addする」という処理をcheckAwaitExpression内にベタ書きすることにしました。

いざ実装

ここまで決めればあとは書くだけです。実装の中身を順番に見ていきましょう。

サブメッセージを作る

まず、今回追加したいサブメッセージを表すDiagnosticオブジェクトを作る必要があります。これもエラーメッセージの一種なわけですが、ご存知のようにエラーメッセージは主に「エラーの場所」と「エラーメッセージ」から構成されます(前者は、DiagnosticRelatedInformationの定義を見れば分かるように、無いこともあるようですが)。

今回のメッセージ、すなわちDid you mean to mark this function as 'async'?がどこを指し示していればいいか考えてみましょう。これは「この関数をasync関数にするんじゃないですか?」的な意味なので、「この関数」を指し示している必要があります。この関数とは、問題のawait式を含む関数です。

ここで、「与えられたノード(今回はAwaitExpressionノード)を含む関数を取得する」という処理が必要になりました。さすがにこの処理を行ってくれる関数はすでにどこかにあるだろうということでソースを検索します。関数名にcontainあたりが入っているだろう、のようなアタリを付けて適当に検索していくとgetContaingFunctionというそれっぽい関数が見つかります。

    export function getContainingFunction(node: Node): SignatureDeclaration | undefined {
        return findAncestor(node.parent, isFunctionLike);
    }

引数はNodeで返り値はSignatureDeclaration | undefinedですね。undefinedの可能性があることは「nodeを含む関数」が存在しないこともあるからでしょう。SignatureDeclarationというのが関数を表すオブジェクトのようなので調べてみましょう。

    export type SignatureDeclaration =
        | CallSignatureDeclaration
        | ConstructSignatureDeclaration
        | MethodSignature
        | IndexSignatureDeclaration
        | FunctionTypeNode
        | ConstructorTypeNode
        | JSDocFunctionType
        | FunctionDeclaration
        | MethodDeclaration
        | ConstructorDeclaration
        | AccessorDeclaration
        | FunctionExpression
        | ArrowFunction;

関数の種類ごとに型が細分化されていますね。ただ、あまり細かく見ていかなくても大丈夫です。ちょっと見ていくと、これらの個別の型は全てSignatureDeclarationBaseの部分型であることが分かります。

    export interface SignatureDeclarationBase extends NamedDeclaration, JSDocContainer {
        kind: SignatureDeclaration["kind"];
        name?: PropertyName;
        typeParameters?: NodeArray<TypeParameterDeclaration>;
        parameters: NodeArray<ParameterDeclaration>;
        type?: TypeNode;
        /* @internal */ typeArguments?: NodeArray<TypeNode>; // Used for quick info, replaces typeParameters for instantiated signatures
    }

さらに継承関係を辿って行くと、これらは結局Nodeであることが分かります。このことが今回重要です。

    export interface Declaration extends Node {
        _declarationBrand: any;
    }

    export interface NamedDeclaration extends Declaration {
        name?: DeclarationName;
    }

すなわち、getContainingFunctionの返り値は(undefinedでなければ)関数を表すNodeオブジェクトだったのです。

特定のノードに対してエラーを発生させるという処理はとてもよくありそうですから、Nodeがあればそれに対するエラーメッセージ(Diagnostic)を生成してくれる関数はすでにありそうです。ということで適当にそれっぽい名前で再び検索します。

すると、createDiagnosticForNodeという関数が見つかります。とりあえずこの部分のコードを書いてみるとこんな感じになります。

const func = getContainingFunction(node);
if (func && func.kind !== SyntaxKind.Constructor) {
    const relatedInfo = createDiagnosticForNode(func, Diagnostics.Did_you_mean_to_mark_this_function_as_async);
}

nodeAwaitExpressionオブジェクトだったので、それを含む関数を取得し、それがあったらcreateDiagnosticForNodeでエラーメッセージを生成します。メッセージが指し示す対象はもちろん当該の関数ノード、すなわちfuncです。

元々のエラーメッセージに追加する

こうしてできたサブメッセージをaddRelatedInfoで元々のエラーメッセージに追加したいですね。元々のエラーメッセージを作る処理は、grammarErrorOnFirstTokenを呼び出す代わりにその中身をベタ書きすることにしましょう。そして、diagnostics.addの前にサブメッセージを追加する処理を挟みます。するとこうなります。

const sourceFile = getSourceFileOfNode(node);
if (!hasParseDiagnostics(sourceFile)) {
    const span = getSpanOfTokenAtPosition(sourceFile, node.pos);
    const diagnostic = createFileDiagnostic(sourceFile, span.start, span.length, Diagnostics.await_expression_is_only_allowed_within_an_async_function);
    const func = getContainingFunction(node);
    if (func) {
        const relatedInfo = createDiagnosticForNode(func, Diagnostics.Did_you_mean_to_mark_this_function_as_async);
        addRelatedInfo(diagnostic, relatedInfo);
    }
    diagnostics.add(diagnostic);
}

元々のエラーメッセージであるdiagnosticを作るところまでをgrammarErrorOnFirstTokenから複製してきました。そして、addRelatedInfoを用いてdiagnosticrelatedInfoを追加していることが分かります。

以上で実装はほとんど終了です。

微調整

最初は以上のような実装にしていたのですが、ひとつ気づいた点がありました。それは、外の関数がコンストラクタだった場合はサブメッセージを付けてはいけないということです。このサブメッセージは関数をasyncにすることを提案するものですが、コンストラクタはasyncにできないからです。

というわけで、funcがコンストラクタの場合はサブメッセージを追加する処理を書くことにしました。そのためにはfuncがコンストラクタかどうか判定できる必要がありますね。ここでfuncSignatureDeclarationだったことを思い出します。

    export type SignatureDeclaration =
        | CallSignatureDeclaration
        | ConstructSignatureDeclaration
        | MethodSignature
        | IndexSignatureDeclaration
        | FunctionTypeNode
        | ConstructorTypeNode
        | JSDocFunctionType
        | FunctionDeclaration
        | MethodDeclaration
        | ConstructorDeclaration
        | AccessorDeclaration
        | FunctionExpression
        | ArrowFunction;

この中にConstructorDeclarationというのがあり、これがいかにもそれっぽいですね。

    export interface ConstructorDeclaration extends FunctionLikeDeclarationBase, ClassElement, JSDocContainer {
        kind: SyntaxKind.Constructor;
        parent: ClassLikeDeclaration;
        body?: FunctionBody;
        /* @internal */ returnFlowNode?: FlowNode;
    }

他にConstructorTypeNodeというのもありますが、こちらは型を表すノードの話なので今回は関係ありません。

ConstructorDeclarationkind: SyntaxKind.Constructor;とあり、SyntaxKindというのはノードの種類を表すenum型です。つまり、これは筆者の既存記事でも解説しているタグ付きunionのパターンになっていることが分かります。よって、funcがコンストラクタかどうか判定するにはfunc.kindSyntaxKind.Constructorかどうかを見ればいいことが分かります。

これにより、コードは以下のようになりました。これで今度こそOKですね。

    if (func && func.kind !== SyntaxKind.Constructor) {
        const relatedInfo = createDiagnosticForNode(func, Diagnostics.Did_you_mean_to_mark_this_function_as_async);
        addRelatedInfo(diagnostic, relatedInfo);
    }

実際のPRでは親切にもアサーションを追加したりしています。

for-await-of文も忘れずに対処する

実は、awaitというのはawait exprのようなawait式の他に、for-await-of文という構文でも登場します。こちらもやはりasync関数の中でしか使えません。

ということで、こちらにも同様に変更を加えました。やっていることはほとんど同じなので実際のコードを見たい方はPRのdiffを参照してください。

似たことをしているので関数に纏めようかとも思いましたが、微妙にやっていることが違っていたのでそのままにしておきました。

既存のテストの修正

以上で実装は終わりましたが、これだけではPRを送ることはできません。テストをちゃんと整備しないといけないのです。

TypeScriptコンパイラのテストは基本的に、テスト用のTypeScriptソースコードに対して実際にコンパイラを走らせ、発生するエラーメッセージや推論される型などが正しいかどうかチェックするというものです。各ソースコードに対してそれに対して期待される結果が書かれたファイルが存在しており(これをbaselineといいます)、テストでは結果がこのファイルの中身と一致しているかどうかが試されます。いわゆるスナップショットテストというやつですね。

CONTRIBUTING.mdに書いてある通り、テストを走らせるには以下のコマンドを使います(全部のテストを走らせるとすごく時間がかかります)。

gulp runtests-parallel

実は、ここまでの変更を加えた段階ではテストが落ちるようになっています。エラーメッセージに変更を加えたので、そのエラーメッセージをチェックするテストが落ちてしまっているのです。

落ちているテストで何がどう変わったのかはgulp diffを実行すると分かります。

今回の場合、テストの側を書き換える必要がありますね。エラーメッセージが変わったのは今回の改善の正しい結果だからです。その場合はbaselineを新しい結果に合わせて書き換えます。以下のコマンドを実行すればOKです。

gulp baseline-accept

テストを書く

また、今回の変更をより網羅的にテストするために、自分でも新しいテストを追加しなければいけません。

具体的には、tests/cases/compiler/awaitInNonAsyncFunction.tsというファイルを作成し以下のようなコードを書きました。

// @target: esnext
// https://github.com/Microsoft/TypeScript/issues/26586

function normalFunc(p: Promise<number>) {
  for await (const _ of []);
  return await p;
}

export function exportedFunc(p: Promise<number>) {
  for await (const _ of []);
  return await p;
}

const functionExpression = function(p: Promise<number>) {
  for await (const _ of []);
  await p;
}

const arrowFunc = (p: Promise<number>) => {
  for await (const _ of []);
  return await p;
};

function* generatorFunc(p: Promise<number>) {
  for await (const _ of []);
  yield await p;
}

class clazz {
  constructor(p: Promise<number>) {
    for await (const _ of []);
    await p;
  }
  method(p: Promise<number>) {
  for await (const _ of []);
    await p;
  }
}

for await (const _ of []);
await null;

今回変更したエラーが発生するawait式やfor-await-of文をひたすら書いたソースコードですね。いろいろな関数タイプを試していたり、関数の中ではない場合もちゃんと試していたりします。

このファイルだけ追加してテストを走らせると、tests/baselines/reference/以下に4つほどファイルが自動生成されます(中身はPRを参照)。これらが新しく追加されたテストに対するbaselineとなります。

ということで、生成されたbaselineファイルたちを目視で確認し、内容に問題が無ければ無事にテストが書けたということになります。

なお、テストを走らせるときは再び全テストを走らせるとものすごく時間がかかるので絞り込みを行う必要があります。ファイル名で絞り込めるのでこんな感じで適当に絞り込みましょう。

gulp runtests --tests=awaitInNonAsyncFunction

まとめ

というわけで、以上で今回筆者が作成したPRの中身と、そこに至るまでの思考の流れを解説しました。TypeScriptに対する初めての貢献でしたが、レビューもたいへんスムーズに進み(余計な改行が入っていたのを指摘されましたがそれだけ)、PRを出してから数時間でマージされました。good first issueに対するPRがいくつも放置されている現状を見るに、コードに対して必要十分かつ的確な変更をすること(あとPRの説明文もいい感じに)が重要であると思われます。

繰り返しになりますが、エラーメッセージの変更は比較的やりやすい部類です。全く新しいエラー判定を追加するのがgood first issueになることはあまり無いでしょうが、既存のエラーメッセージがわかりにくいので改善するというようなタスクは結構あります。

その際はただエラーメッセージの文面を変更するだけでなく、(今回外側の関数を探す処理を追加したよおうに)何らかの追加情報を集める処理を書かなければいけないことが多いでしょう。それがエラーメッセージ改善の肝であるといえます。そのような部分は場合に応じてアドリブで進めていく必要があります。この記事で事細かに述べた思考過程がその参考になれば幸いです。ソースコードを全部理解する必要はなく、必要な部分を調べれば大抵は大丈夫です。

とはいえ、ある程度コンパイラの全体像を把握していたほうがコードの理解・予測がしやすいのは間違いありません。例えば、記事中に出てきたcheckAwaitExpressionはベーシックな型検査アルゴリズム(抽象構文木に沿って再帰的に制約を作ったり型を生成したりする)のイメージが頭の中にあればその意味が自然に理解できます2。興味が出てきたらその方面を調べてみるのもよいかもしれません。

とにかく、皆さんもぜひTypeScriptに貢献しましょう。たまにgood first issueが発生するので、いけそうだなと思ったら他の人に取られる前にissueにやりますと書きましょう(筆者が取ったissueはしばらく放置されていたのでいきなりPRを出しましたが)。

やり方をTypeScriptチームの人に聞くとたまに教えてもらえますが、返事が返ってこないことも多いです。まあ、いちいち教えてあげなくても(筆者のように😤)勝手にソースを読み解いてPRを送ってくる人もいるわけですから、手取り足取り教えてあげる時間が勿体無いのかもしれません。この記事を参考に自力でTypeScriptコンパイラのコードが読めるようになるといいですね。

この記事を参考に皆さんもぜひやってみてください。

著作権表示

記事中に掲載したTypeScriptコンパイラのソースコードはApache 2.0 ライセンスのもと公開されており、著作権表示は以下の通りです。

/*! *****************************************************************************
Copyright (c) Microsoft Corporation. All rights reserved. 
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License. You may obtain a copy of the
License at http://www.apache.org/licenses/LICENSE-2.0  

THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED
WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, 
MERCHANTABLITY OR NON-INFRINGEMENT. 

See the Apache Version 2.0 License for specific language governing permissions
and limitations under the License.
***************************************************************************** */

  1. 抽象構文木 (AST) とは、プログラムのソースコードを意味的に表す木構造です。木構造とかよく分からないという方はとりあえずプログラムのソースコードを表すオブジェクトだと思っておきましょう。プログラムの各単位(文とか式とか)に対して対応するオブジェクト(ノードと呼ばれる)が存在します。例えばx + 1という式全体に対応するノードは、自身が+式であるという情報、左の式(子ノード)と右の式(子ノード)がそれぞれ何かという情報を持つオブジェクトです。この場合左の子ノードはxという式に対応するノードであり、右の子ノードは1という式に対応するノードです。 

  2. TypeScriptの型推論詳説で解説したように、TypeScriptの型推論はこの方式にそってワンパスで行なわれるものです。関数型言語などでは、木に沿って制約を生成したあと制約を解消するというものになったりして少し複雑です。 

90
44
1

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
90
44