お詫び
当初、 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 のメンバーを列挙する関数について紹介します。
成果物
- github: https://github.com/kimamula/ts-transformer-enumerate
- npm: https://www.npmjs.com/package/ts-transformer-enumerate
お題
以下のような 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 にどのような変化が起きるか、期待していきたいです。