Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How do I type check a snippet of TypeScript code in memory?

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.

like image 275
Ashley Davis Avatar asked Dec 11 '18 22:12

Ashley Davis


2 Answers

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.

like image 100
Ashley Davis Avatar answered Oct 24 '22 15:10

Ashley Davis


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.

  1. Implement a ts.CompilerHost where methods like fileExists, readFile, directoryExists, getDirectories(), etc. read from memory instead of the actual file system.
  2. 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).
  3. Add your in memory file to the in memory file system as well.
  4. Create a program (using ts.createProgram) and pass in your custom ts.CompilerHost.
  5. 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
like image 22
David Sherret Avatar answered Oct 24 '22 15:10

David Sherret