Edited at

[TypeScript 2.4] custom transformer を利用して実行時に型情報を参照可能にする

More than 1 year has passed since last update.


お詫び

当初、 TypeScript 2.3 の機能として紹介していましたが、正しくは 2.4 でした。

誤った情報を掲載してしまい、申し訳ありません。

TypeScript 2.3.1 では、 API として custom transformer を指定できるようにはなっているものの、 bug fix が取り込まれておらず、正常には使えない状態です。


以下、本文

現在の typescript@next では、コンパイル時に custom transformer を指定できるようになり、これによりコンパイラ機能の拡張が可能になりました。

参考: https://github.com/Microsoft/TypeScript/pull/13940

この記事では、この機能を使ってできることの例として、 string literal type の union のメンバーを列挙する関数について紹介します。


成果物


お題

以下のような signature の関数を実現します。

export declare function enumerate<T extends string>(): { [K in T]: K };

こんな風に使えます。

import { enumerate } from 'ts-transformer-enumerate';

type Colors = 'green' | 'yellow' | 'red';
const Colors = enumerate<Colors>();

console.log(Colors.green); // 'green'
console.log(Colors.yellow); // 'yellow'
console.log(Colors.red); // 'red'

これを実現するということは、 type Colors という TypeScript の型情報を、コンパイル時だけでなく、実行時にも参照できるようにする、ということです。

TypeScript に詳しい方ならご存知の通り、これは TypeScript のコンパイラをそのまま使う限り、おそらく将来にわたって不可能なことです。

なぜなら、 TypeScript Design Goals に、 "Non-goals" として以下のように明記されているからです。


5. Add or rely on run-time type information in programs, or emit different code based on the results of the type system. Instead, encourage programming patterns that do not require run-time metadata.



種明かし

上記のコードを、作成した custom transformer を使ってコンパイルすると、以下のような JavaScript が出力されます。

"use strict";

Object.defineProperty(exports, "__esModule", { value: true });
var ts_transformer_enumerate_1 = require("ts-transformer-enumerate");
var Colors = { green: "green", yellow: "yellow", red: "red" };
console.log(Colors.green); // 'green'
console.log(Colors.yellow); // 'yellow'
console.log(Colors.red); // 'red'

つまり、 enumerate 関数の呼び出しを、 custom transformer でいい感じに置換してやる、ということです。


custom transformer の実装

詳細に説明するのが面倒なので、直接コードを載せます。

何となく流れを感じ取っていただければ。

AST をたどっていくイメージです。 (AST あまり詳しくないですが、たぶん)

import * as ts from 'typescript';

import * as path from 'path';

export default function transformer(program: ts.Program): ts.TransformerFactory<ts.SourceFile> {
return (context: ts.TransformationContext) => (file: ts.SourceFile) => visitNodeAndChildren(file, program, context);
}

function visitNodeAndChildren(node: ts.SourceFile, program: ts.Program, context: ts.TransformationContext): ts.SourceFile;
function visitNodeAndChildren(node: ts.Node, program: ts.Program, context: ts.TransformationContext): ts.Node;
function visitNodeAndChildren(node: ts.Node, program: ts.Program, context: ts.TransformationContext): ts.Node {
return ts.visitEachChild(visitNode(node, program), childNode => visitNodeAndChildren(childNode, program, context), context);
}

function visitNode(node: ts.Node, program: ts.Program): ts.Node {
const typeChecker = program.getTypeChecker();
// 置換対象の node かどうか (enumerate 関数の呼び出しかどうか) を判定
if (!isEnumerateCallExpression(node, typeChecker)) {
return node;
}

// enumerate 関数の呼び出しなら、 typeArguments から型情報を読み取る
const stringLiteralTypes: string[] = [];
node.typeArguments && resolveStringLiteralTypes(node.typeArguments[0], typeChecker, stringLiteralTypes);

// 読み取った型情報をもとに、 object literal を生成して返すと、元の node が置換される
return ts.createObjectLiteral(stringLiteralTypes.map(stringLiteralType => {
// unquote string literal type
const propertyName = stringLiteralType.substring(1, stringLiteralType.length - 1);
return ts.createPropertyAssignment(propertyName, ts.createLiteral(propertyName));
}));
}

function isEnumerateCallExpression(node: ts.Node, typeChecker: ts.TypeChecker): node is ts.CallExpression {
if (node.kind !== ts.SyntaxKind.CallExpression) {
return false;
}
const { declaration } = typeChecker.getResolvedSignature(node as ts.CallExpression);
return !!declaration
&& (declaration.getSourceFile().fileName === path.resolve(__dirname, '..', 'index.ts'))
&& !!declaration.name
&& (declaration.name.getText() === 'enumerate');
}

function resolveStringLiteralTypes(node: ts.Node, typeChecker: ts.TypeChecker, stringLiteralTypes: string[]): void {
switch (node.kind) {
case ts.SyntaxKind.TypeReference:
const symbol = typeChecker.getSymbolAtLocation((node as ts.TypeReferenceNode).typeName);
symbol.declarations && symbol.declarations[0].forEachChild(node => resolveStringLiteralTypes(node, typeChecker, stringLiteralTypes));
break;
case ts.SyntaxKind.UnionType:
node.forEachChild(node => resolveStringLiteralTypes(node, typeChecker, stringLiteralTypes));
break;
case ts.SyntaxKind.LiteralType:
const text = (node as ts.LiteralTypeNode).getText();
stringLiteralTypes.indexOf(text) < 0 && stringLiteralTypes.push(text);
break;
default:
break;
}
}

公開された API の使い方については現時点で公式のドキュメントがなく、私自身手探りで実装しましたが、きっとそのうち公開されると思うので、正確で詳細な情報については、その時にそちらをご参照いただけると良いと思います。

さわりだけ説明すると、ここでは node の種別(関数呼び出し、型参照等)の判定に node.kind を利用し、型の具体的な情報を知るために、 ts.TypeChecker を利用する、ということをやっています。


custom transformer を利用したコンパイル

非常に残念なことに、 custom transformer を使うためには TypeScript のコンパイルを Node.js の API を利用して行わなければなりません。

以下のような感じです。

const ts = require('typescript');

const enumerateTransformer = require('ts-transfomer-enumerate/transformer').default;

const program = ts.createProgram([/* your files to compile */], {
strict: true,
noEmitOnError: true,
target: ts.ScriptTarget.ES5
});

const transformers = {
before: [enumerateTransformer(program)],
after: []
};
const { emitSkipped, diagnostics } = program.emit(undefined, undefined, undefined, false, transformers);

if (emitSkipped) {
throw new Error(diagnostics.map(diagnostic => diagnostic.messageText).join('\n'));
}

当然ながら、 tsconfig.json とかで指定して普通に tsc で動くようにしてほしい、という要望が上がっており、今後に期待したいところです。

https://github.com/Microsoft/TypeScript/issues/14419


終わりに

実行時に型情報を参照するというのは、 TypeScript を長年(といっても3年くらいですが)愛用してきた身からすると、禁断の果実に手を出してしまう気分です(なので実際のところ濫用は避けたほうがいいかなと思っています)。

「実行時に型情報を参照する」という方向性でも、ここの例以外に色々な用途があるでしょうが、もちろんまったく異なる方向性にも、 custom transformer の使い道はあります。

https://github.com/Microsoft/TypeScript/pull/13940 に貼られている、他の project の issue, PR からのリンクを見ていると、どういうことに custom transformer が使われているか/使われようとしているかが分かって、なかなか興味深いです。

例えば、以下のようなものがあります。

custom transformer が今後どのように使われていくか、それによって TypeScript にどのような変化が起きるか、期待していきたいです。