Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/execution-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,40 @@ jobs:
- name: test
run: npm run execution-tests

# Node 16 can only load TypeScript 7 through a `compiler` passed from an ESM
# webpack config. Install and build on Node 26 (Jasmine 7 and the dev tooling
# need it), then switch to Node 16 for suppliedCompiler: a build-only test,
# so it never loads Jasmine.
execution_test_node16:
name: ubuntu node 16
strategy:
matrix:
ts: [7.1.0-beta, next]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: install node
uses: actions/setup-node@v7
with:
node-version: 26
cache: npm

- uses: ./.github/actions/build-ts-loader

- name: install typescript
run: npm install -D typescript@${{ matrix.ts }}

- name: switch to node 16
uses: actions/setup-node@v7
with:
node-version: 16

- name: test
run: |
node --version
node test/execution-tests/run-tests.js --single-test suppliedCompiler

execution_test_macos:
name: macOS
strategy:
Expand Down
28 changes: 24 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -409,11 +409,31 @@ If `true`, `ts-loader` writes nothing to the console. Errors reported through we

#### compiler

| Type | Default Value |
| -------- | -------------- |
| `string` | `'typescript'` |
| Type | Default Value |
| ------------------------------------------ | -------------- |
| `string \| { API, createFileSystemLayer }` | `'typescript'` |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the documentation is correct - here; it's an object that's passed and default value is not 'typescript'?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

default is typescript used here https://github.com/TypeStrong/ts-loader/pull/1725/changes/BASE..f110b488792612b481dea18cdd36fb998b123170#diff-1bded13bfea4654df3b721738a266a835230e5539fe6fd27bc822e42a01ecd6fR44

const compiler =
    typeof loaderOptions.compiler === 'object'
      ? loaderOptions.compiler
      : requireCompiler(loaderOptions.compiler ?? 'typescript');

@johnnyreilly johnnyreilly Nov 13, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's true - but it's quite peculiar!

Effectively the type is (I think):

'typescript' | 
{ 
    createFileSystemLayer: (files: RequestFileEntries, options?: CreateFileSystemOptions) => RequestFileSystem;
    API: new (options?: APIOptions) => API;
}

I think it would be better if the default was undefined and that 'typescript' | was removed from the union. Do you see what I mean?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but we can pass string like 'typescript-beta' or some other string that will be resolved in requireCompiler
maybe I am missing something

I tried to keep changes minimal, so that old configs would keep on working and only node 16 users would need to change webpack configs (pass object with API and createFileSystemLayer)

so if compiler option is missing default 'typescript' is used and required in requireCompiler


Use a TypeScript package other than the one installed as `typescript`, such as a fork or a prerelease installed under an alias. The package must provide TypeScript's native API at `<compiler>/sync` (for example `typescript/sync`). Packages that only provide the classic compiler API, such as `ttypescript`, are not supported.
Use a TypeScript package other than the one installed as `typescript`, such as a fork or a prerelease installed under an alias. The package must provide TypeScript's native API at `<compiler>/sync` and `<compiler>/fs`. Packages that only provide the classic compiler API, such as `ttypescript`, are not supported.

A package name is loaded with `require()`, which needs Node.js 20.19+ / 22.12+ because TypeScript 7 is ESM-only. On older Node.js (16.20+), import the modules in your webpack config and pass them instead. If two rules pass different `compiler` modules, give them different `instance` names.

```js
// webpack.config.mjs
import { API } from 'typescript/sync';
import { createFileSystemLayer } from 'typescript/fs';

export default {
module: {
rules: [
{
test: /\.tsx?$/,
loader: 'ts-loader',
options: { compiler: { API, createFileSystemLayer } },
},
],
},
};
```

#### instance

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"ts"
],
"engines": {
"node": ">=22.0.0"
"node": ">=16.20.0"
},
"author": "John Reilly <johnny_reilly@hotmail.com> (https://johnnyreilly.com)",
"contributors": [
Expand Down
12 changes: 5 additions & 7 deletions src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,8 +295,7 @@ function removeCompilationTSLoaderErrors(
loaderOptions: LoaderOptions,
) {
compilation.errors = compilation.errors.filter(
error =>
!isTSLoaderModuleError(error as webpack.WebpackError, loaderOptions),
error => !isTSLoaderModuleError(error, loaderOptions),
);
}

Expand Down Expand Up @@ -328,11 +327,10 @@ function removeModuleTSLoaderError(
}
}

function isTSLoaderModuleError(
error: webpack.WebpackError,
loaderOptions: LoaderOptions,
) {
return error?.details === tsLoaderSource(loaderOptions);
function isTSLoaderModuleError(error: Error, loaderOptions: LoaderOptions) {
return (
(error as webpack.WebpackError)?.details === tsLoaderSource(loaderOptions)
);
}

function moduleHasWebpackErrors(module: webpack.Module) {
Expand Down
9 changes: 7 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,13 @@ function getTypeScriptInstance(
}

setTSInstanceInCache(compiler, loaderOptions.instance, instance);
log.logInfo(`Using ${loaderOptions.compiler} API with ${configFilePath}`);
log.logInfo(
`Using ${
typeof loaderOptions.compiler === 'object'
? 'supplied compiler'
: (loaderOptions.compiler ?? 'typescript')
} API with ${configFilePath}`,
);
return instance;
}

Expand Down Expand Up @@ -442,7 +448,6 @@ function getLoaderOptions(loaderContext: webpack.LoaderContext<LoaderOptions>) {
silent: false,
logLevel: 'WARN' as keyof typeof LogLevel,
logInfoToStdOut: false,
compiler: 'typescript',
transpileOnly: hasForkTsCheckerWebpackPlugin,
appendTsSuffixTo: [],
appendTsxSuffixTo: [],
Expand Down
35 changes: 20 additions & 15 deletions src/typeScriptApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ import * as fs from 'fs';
import * as path from 'path';
import type * as webpack from 'webpack';
import type {
API,
APIOptions,
CompilerOptions,
CreateSnapshotParams,
Diagnostic,
Expand All @@ -15,7 +13,6 @@ import type {
SyntheticProjectId,
} from 'typescript/sync';
import type { StringLiteralLikeNode } from 'typescript/ast';
import { createFileSystemLayer } from 'typescript/fs';

import * as constants from './constants';
import {
Expand All @@ -29,29 +26,30 @@ import type {
LoaderOptions,
PendingDeclarationFile,
SyntheticProgram,
TypeScriptCompilerModules,
TypeScriptInstance,
TSInstance,
ResolvedFilePathCache,
} from './types';
import { isWebpack5 } from './loaderUtils';

interface TypeScriptApiModule {
API: new (options?: APIOptions) => API;
}

export function createTypeScriptInstance(
loaderOptions: LoaderOptions,
configFilePath: string,
useCaseSensitiveFileNames: boolean,
): TypeScriptInstance {
const typeScriptApiModule = loadTypeScriptApiModule(loaderOptions.compiler);
const compiler =
typeof loaderOptions.compiler === 'object'
? loaderOptions.compiler
: requireCompiler(loaderOptions.compiler ?? 'typescript');
// No `fs` callbacks: the API reads disk directly, with webpack's content
// layered on top per file (see layerFileContents). Case sensitivity is
// passed explicitly so the API agrees with `resolvedFilePathCache`.
const api = new typeScriptApiModule.API({ useCaseSensitiveFileNames });
const api = new compiler.API({ useCaseSensitiveFileNames });

return {
api,
compiler,
configFilePath: path.resolve(configFilePath),
transpileConfig: undefined,
orphanRootFileNames: new Map(),
Expand Down Expand Up @@ -500,14 +498,19 @@ function computeDeclarationFilePath(
return path.join(outDirLikeOption, relativeToRoot);
}

function loadTypeScriptApiModule(compilerPackage: string): TypeScriptApiModule {
const specifier = `${compilerPackage}/sync`;

/**
* Used when `compiler` is a package name (or not passed). TypeScript 7 is
* ESM-only, so this needs require(esm) (Node 20.19+ / 22.12+); on older Node,
* pass `compiler: { API, createFileSystemLayer }` instead.
*/
function requireCompiler(compilerPackage: string): TypeScriptCompilerModules {
try {
return require(specifier) as TypeScriptApiModule;
const { API } = require(`${compilerPackage}/sync`);
const { createFileSystemLayer } = require(`${compilerPackage}/fs`);
return { API, createFileSystemLayer };
} catch (error) {
throw new Error(
`Could not load the TypeScript API from "${specifier}": ${
`Could not load the TypeScript API from "${compilerPackage}": ${
error instanceof Error ? error.message : String(error)
}`,
);
Expand Down Expand Up @@ -710,7 +713,9 @@ function layerFileContents(
contents: string,
): Snapshot {
const fileLayer: SnapshotChanges = {
fileSystem: createFileSystemLayer([[fileName, contents]]),
fileSystem: typeScriptInstance.compiler.createFileSystemLayer([
[fileName, contents],
]),
ensurePrograms: true,
};
if (
Expand Down
15 changes: 14 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { Colors } from './colors';
import type {
API,
APIOptions,
Diagnostic,
ParsedCommandLine,
Snapshot,
SyntheticProjectId,
} from 'typescript/sync';
import type { createFileSystemLayer } from 'typescript/fs';
import type * as webpack from 'webpack';

export interface ErrorInfo {
Expand Down Expand Up @@ -38,12 +40,22 @@ export const LogLevel = {
ERROR: 3,
} as const;

/** `API` from `typescript/sync` and `createFileSystemLayer` from `typescript/fs`. */
export interface TypeScriptCompilerModules {
API: new (options?: APIOptions) => API;
createFileSystemLayer: typeof createFileSystemLayer;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would this approach work if we didn't pass createFileSystemLayer - my guess is no, but I'm curious

}

export interface LoaderOptions {
silent: boolean;
logLevel: keyof typeof LogLevel;
logInfoToStdOut: boolean;
instance: string;
compiler: string;
/**
* A package name (loaded with `require()`, needs Node 20.19+ / 22.12+), or
* the already-imported modules. Defaults to `'typescript'`.
*/
compiler?: string | TypeScriptCompilerModules;
configFile: string;
transpileOnly: boolean;
ignoreDiagnostics: number[];
Expand Down Expand Up @@ -82,6 +94,7 @@ export interface SyntheticProgram {

export interface TypeScriptInstance {
api: API;
compiler: TypeScriptCompilerModules;
/** Real-cased, as given to the API - not a canonical key (see FilePath). */
configFilePath: string;
transpileConfig?: ParsedCommandLine;
Expand Down
2 changes: 1 addition & 1 deletion test/execution-tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ These tests are executed more widely that the comparison tests; we aim to run th

## Structure

The execution test pack can be found under `/test/execution-tests`. Like the comparison test pack, the execution test pack uses certain conventions. All tests have their own directory under `/test/execution-tests`, eg `/test/execution-tests/someFeature`, named after the feature being tested. Each test has a `webpack.config.js` file and usually a `main.js` file that loads its specs:
The execution test pack can be found under `/test/execution-tests`. Like the comparison test pack, the execution test pack uses certain conventions. All tests have their own directory under `/test/execution-tests`, eg `/test/execution-tests/someFeature`, named after the feature being tested. Each test has a `webpack.config.js` file; build-only tests may use `webpack.config.mjs` for an ESM config. Tests with specs usually also have a `main.js` file that loads them:

```js
const testsContext = require.context('./', true, /\.tests\.ts(x?)$/);
Expand Down
21 changes: 17 additions & 4 deletions test/execution-tests/run-webpack.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,33 @@
'use strict';

// Builds the webpack.config.js in the current directory once, like `webpack --bail`,
// Builds the webpack config in the current directory once, like `webpack --bail`,
// so the execution tests don't need webpack-cli. Exits with 1 if the build has errors.
// Like webpack-cli, the config can export an object, a function or a Promise, or an
// array of them.
// array of them. ESM configs can use webpack.config.mjs.
const fs = require('fs');
const path = require('path');
const { pathToFileURL } = require('url');
const webpack = require('webpack');

const exported = require(path.resolve('webpack.config.js'));
async function loadWebpackConfig() {
const jsConfigPath = path.resolve('webpack.config.js');
if (fs.existsSync(jsConfigPath)) {
return require(jsConfigPath);
}

const { default: config } = await import(
pathToFileURL(path.resolve('webpack.config.mjs')).href
);
return config;
}

const env = { WEBPACK_BUNDLE: true, WEBPACK_BUILD: true };
// What webpack-cli passes a config function for `webpack --bail`
const argv = { env, bail: true };

// Starting from Promise.resolve() means a config function that throws is caught below
Promise.resolve()
.then(() => resolveConfig(exported))
.then(() => resolveConfig(loadWebpackConfig()))
.then((config) => (Array.isArray(config) ? Promise.all(config.map(resolveConfig)) : config))
.then((config) => {
(Array.isArray(config) ? config : [config]).forEach((c) => { c.bail = true; });
Expand Down
1 change: 1 addition & 0 deletions test/execution-tests/suppliedCompiler/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const compiledWithSuppliedCompiler = true;
30 changes: 30 additions & 0 deletions test/execution-tests/suppliedCompiler/webpack.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { fileURLToPath } from "node:url";
import { API } from "typescript/sync";
import { createFileSystemLayer } from "typescript/fs";

export default {
mode: "development",
entry: "./app.ts",
output: {
filename: "bundle.js",
},
resolve: {
extensions: [".ts", ".js"],
},
module: {
rules: [
{
test: /\.ts$/,
loader: "ts-loader",
options: {
compiler: { API, createFileSystemLayer },
},
},
],
},
resolveLoader: {
alias: {
"ts-loader": fileURLToPath(new URL("../../../index.js", import.meta.url)),
},
},
};
Loading