I'm implementing TypeScript support into my application Data-Forge Notebook.
I need to compile, type check and evaluate snippets of TypeScript code.
Compilation appears to be no problem, I'm using transpileModule
as shown below to convert a snippet of TS code into JavaScript code that can be evaluated:
import { transpileModule, TranspileOptions } from "typescript";
const transpileOptions: TranspileOptions = {
compilerOptions: {},
reportDiagnostics: true,
};
const tsCodeSnippet = " /* TS code goes here */ ";
const jsOutput = transpileModule(tsCodeSnippet, transpileOptions);
console.log(JSON.stringify(jsOutput, null, 4));
However there is a problem when I try an compile TS code that has an error.
For example the following function has a type error, yet it is transpiled without any error diagnostics:
function foo(): string {
return 5;
}
Transpiling is great, but I'd also like to be able to display errors to my user.
So my question is how can do this but also do type checking and produce errors for semantic errors?
Note that I don't want to have to save the TypeScript code to a file. That would be an unecessary performance burden for my application. I only want to compile and type check snippets of code that are held in memory.
I've solved this problem building on some original help from David Sherret and then a tip from Fabian Pirklbauer (creator of TypeScript Playground).
I've created a proxy CompilerHost to wrap a real CompilerHost. The proxy is capable of returning the in-memory TypeScript code for compilation. The underlying real CompilerHost is capable of loading the default TypeScript libraries. The libraries are needed otherwise you get loads of errors relating to built-in TypeScript data types.
Code
import * as ts from "typescript";
//
// A snippet of TypeScript code that has a semantic/type error in it.
//
const code
= "function foo(input: number) {\n"
+ " console.log('Hello!');\n"
+ "};\n"
+ "foo('x');"
;
//
// Result of compiling TypeScript code.
//
export interface CompilationResult {
code?: string;
diagnostics: ts.Diagnostic[]
};
//
// Check and compile in-memory TypeScript code for errors.
//
function compileTypeScriptCode(code: string, libs: string[]): CompilationResult {
const options = ts.getDefaultCompilerOptions();
const realHost = ts.createCompilerHost(options, true);
const dummyFilePath = "/in-memory-file.ts";
const dummySourceFile = ts.createSourceFile(dummyFilePath, code, ts.ScriptTarget.Latest);
let outputCode: string | undefined = undefined;
const host: ts.CompilerHost = {
fileExists: filePath => filePath === dummyFilePath || realHost.fileExists(filePath),
directoryExists: realHost.directoryExists && realHost.directoryExists.bind(realHost),
getCurrentDirectory: realHost.getCurrentDirectory.bind(realHost),
getDirectories: realHost.getDirectories.bind(realHost),
getCanonicalFileName: fileName => realHost.getCanonicalFileName(fileName),
getNewLine: realHost.getNewLine.bind(realHost),
getDefaultLibFileName: realHost.getDefaultLibFileName.bind(realHost),
getSourceFile: (fileName, languageVersion, onError, shouldCreateNewSourceFile) => fileName === dummyFilePath
? dummySourceFile
: realHost.getSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile),
readFile: filePath => filePath === dummyFilePath
? code
: realHost.readFile(filePath),
useCaseSensitiveFileNames: () => realHost.useCaseSensitiveFileNames(),
writeFile: (fileName, data) => outputCode = data,
};
const rootNames = libs.map(lib => require.resolve(`typescript/lib/lib.${lib}.d.ts`));
const program = ts.createProgram(rootNames.concat([dummyFilePath]), options, host);
const emitResult = program.emit();
const diagnostics = ts.getPreEmitDiagnostics(program);
return {
code: outputCode,
diagnostics: emitResult.diagnostics.concat(diagnostics)
};
}
console.log("==== Evaluating code ====");
console.log(code);
console.log();
const libs = [ 'es2015' ];
const result = compileTypeScriptCode(code, libs);
console.log("==== Output code ====");
console.log(result.code);
console.log();
console.log("==== Diagnostics ====");
for (const diagnostic of result.diagnostics) {
console.log(diagnostic.messageText);
}
console.log();
Output
==== Evaluating code ====
function foo(input: number) {
console.log('Hello!');
};
foo('x');
=========================
Diagnosics:
Argument of type '"x"' is not assignable to parameter of type 'number'.
Full working example available on my Github.
This is not a straightforward task and may take a little while to do. Perhaps there is an easier way, but I haven't found one yet.
ts.CompilerHost
where methods like fileExists
, readFile
, directoryExists
, getDirectories()
, etc. read from memory instead of the actual file system.ts.createProgram
) and pass in your custom ts.CompilerHost
.ts.getPreEmitDiagnostics(program)
to get the diagnostics.Imperfect Example
Here's a short imperfect example that does not properly implement an in memory file system and does not load the lib files (so there will be global diagnostic errors... those can be ignored or you could call specific methods on program
other than program.getGlobalDiagnostics()
. Note the behaviour of ts.getPreEmitDiagnostics
here):
import * as ts from "typescript";
console.log(getDiagnosticsForText("const t: number = '';").map(d => d.messageText));
function getDiagnosticsForText(text: string) {
const dummyFilePath = "/file.ts";
const textAst = ts.createSourceFile(dummyFilePath, text, ts.ScriptTarget.Latest);
const options: ts.CompilerOptions = {};
const host: ts.CompilerHost = {
fileExists: filePath => filePath === dummyFilePath,
directoryExists: dirPath => dirPath === "/",
getCurrentDirectory: () => "/",
getDirectories: () => [],
getCanonicalFileName: fileName => fileName,
getNewLine: () => "\n",
getDefaultLibFileName: () => "",
getSourceFile: filePath => filePath === dummyFilePath ? textAst : undefined,
readFile: filePath => filePath === dummyFilePath ? text : undefined,
useCaseSensitiveFileNames: () => true,
writeFile: () => {}
};
const program = ts.createProgram({
options,
rootNames: [dummyFilePath],
host
});
return ts.getPreEmitDiagnostics(program);
}
If you have access to the file system then this is a lot easier and you can use a function similar to the one below:
import * as path from "path";
function getDiagnosticsForText(
rootDir: string,
text: string,
options?: ts.CompilerOptions,
cancellationToken?: ts.CancellationToken
) {
options = options || ts.getDefaultCompilerOptions();
const inMemoryFilePath = path.resolve(path.join(rootDir, "__dummy-file.ts"));
const textAst = ts.createSourceFile(inMemoryFilePath, text, options.target || ts.ScriptTarget.Latest);
const host = ts.createCompilerHost(options, true);
overrideIfInMemoryFile("getSourceFile", textAst);
overrideIfInMemoryFile("readFile", text);
overrideIfInMemoryFile("fileExists", true);
const program = ts.createProgram({
options,
rootNames: [inMemoryFilePath],
host
});
return ts.getPreEmitDiagnostics(program, textAst, cancellationToken);
function overrideIfInMemoryFile(methodName: keyof ts.CompilerHost, inMemoryValue: any) {
const originalMethod = host[methodName] as Function;
host[methodName] = (...args: unknown[]) => {
// resolve the path because typescript will normalize it
// to forward slashes on windows
const filePath = path.resolve(args[0] as string);
if (filePath === inMemoryFilePath)
return inMemoryValue;
return originalMethod.apply(host, args);
};
}
}
// example...
console.log(getDiagnosticsForText(
__dirname,
"import * as ts from 'typescript';\n const t: string = ts.createProgram;"
));
Doing it this way, the compiler will search the provided rootDir
for a node_modules
folder and use the typings in there (they don't need to be loaded into memory in some other way).
I've created a library called @ts-morph/bootstrap that makes getting setup with the Compiler API much easier. It will load in TypeScript lib files for you too even when using an in memory file system.
import { createProject, ts } from "@ts-morph/bootstrap";
const project = await createProject({ useInMemoryFileSystem: true });
const myClassFile = project.createSourceFile(
"MyClass.ts",
"export class MyClass { prop: string; }",
);
const program = project.createProgram();
ts.getPreEmitDiagnostics(program); // check these
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With