How do I type check a snippet of TypeScript code in memory?
Situation 1 - Using only memory - No Access to File System (Ex. on the web)
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.
- Implement a
ts.CompilerHost
where methods likefileExists
,readFile
,directoryExists
,getDirectories()
, etc. read from memory instead of the actual file system. - Load in the appropriate lib files into your in memory file system depending on what you need (ex. lib.es6.d.ts or lib.dom.d.ts).
- Add your in memory file to the in memory file system as well.
- Create a program (using
ts.createProgram
) and pass in your customts.CompilerHost
. - Call
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);
}
Situation 2 - Access to the file system
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).
Update: Easiest Solution
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
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.