verificarea tipurilor - Cum pot verifica tipurile unui fragment de cod TypeScript în memorie?


Implement suport TypeScript în aplicația mea Data-Forge Notebook.

Trebuie să compilez, să verific tipurile și să evaluez fragmente de cod TypeScript.

Compilarea pare să nu reprezinte nicio problemă, folosesc transpileModule așa cum este arătat mai jos pentru a converti un fragment de cod TS în cod JavaScript care poate fi evaluat:

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));

Totuși, există o problemă atunci când încerc să compilez cod TS care are o eroare.

De exemplu, funcția următoare are o eroare de tip, totuși este transpilată fără nicio diagnosticare a erorilor:

function foo(): string {
    return 5;
}

Transpilarea este grozavă, dar mi-ar plăcea, de asemenea, să pot afișa erori utilizatorului meu.

Deci întrebarea mea este cum pot face asta, dar și să efectuez verificarea tipurilor și să produc erori pentru erorile semantice?

Rețineți că nu vreau să fie nevoie să salvez codul TypeScript într-un fișier. Aceasta ar fi o povară inutilă pentru performanța aplicației mele. Vreau doar să compilez și să verific tipurile pentru fragmente de cod care sunt păstrate în memorie.

Răspuns #1

Situația 1 - Utilizarea doar a memoriei - Fără acces la sistemul de fișiere (Ex. pe web)

Aceasta nu este o sarcină simplă și poate dura puțin timp să fie realizată. Poate există o cale mai ușoară, dar nu am găsit una încă.

  1. Implementați un ts.CompilerHost unde metode precum fileExists, readFile, directoryExists, getDirectories() etc. să citească din memorie în loc de sistemul real de fișiere.
  2. Încarcă fișierele lib corespunzătoare în sistemul tău de fișiere în memorie, în funcție de ce ai nevoie (ex. lib.es6.d.ts sau lib.dom.d.ts).
  3. Adăugați fișierul din memorie și în sistemul de fișiere din memorie.
  4. Creează un program (folosind ts.createProgram) și transmite-ți ts.CompilerHost personalizat.
  5. Apelează ts.getPreEmitDiagnostics(program) pentru a obține diagnosticele.

Exemplu de imperfect

Iată un scurt exemplu imperfect care nu implementează corect un sistem de fișiere în memorie și nu încarcă fișierele lib (așa că vor exista erori globale de diagnosticare... acestea pot fi ignorate sau ai putea apela metode specifice pe program în afară de program.getGlobalDiagnostics(). Observă comportamentul lui ts.getPreEmitDiagnostics aici):

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);
}

Situația 2 - Accesul la sistemul de fișiere

Dacă ai acces la sistemul de fișiere, atunci acest lucru este mult mai ușor și poți folosi o funcție similară cu cea de mai jos:

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;"
));

Făcând acest lucru în acest fel, compilatorul va căuta în rootDir furnizat un folder node_modules și va folosi tipurile de acolo (nu trebuie să fie încărcate în memorie în alt mod).

Actualizare: Cea mai ușoară soluție

Am creat o bibliotecă numită @ts-morph/bootstrap care face configurarea cu API-ul Compiler mult mai ușoară. Aceasta va încărca și fișierele lib TypeScript pentru tine, chiar și atunci când folosești un sistem de fișiere în memorie.

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

Răspunsul #2

Am rezolvat această problemă bazându-mă pe o ajutor inițial de la David Sherret și apoi pe un sfat de la Fabian Pirklbauer (creatorul TypeScript Playground).

Am creat un proxy CompilerHost pentru a înveli un CompilerHost real. Proxy-ul este capabil să returneze codul TypeScript din memorie pentru compilare. CompilerHost-ul real de bază este capabil să încarce bibliotecile TypeScript implicite. Bibliotecile sunt necesare altfel vei primi o mulțime de erori legate de tipurile de date încorporate în TypeScript.

Cod

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();

Ieșire

==== Evaluating code ====
function foo(input: number) {
    console.log('Hello!');
};
foo('x');
=========================
Diagnosics:
Argument of type '"x"' is not assignable to parameter of type 'number'.

Exemplu complet de lucru disponibil pe Github-ul meu.