diff --git a/.changeset/typescript-seven-builds.md b/.changeset/typescript-seven-builds.md
index 3f9d30ffcf1..68406409833 100644
--- a/.changeset/typescript-seven-builds.md
+++ b/.changeset/typescript-seven-builds.md
@@ -10,4 +10,4 @@
"@trigger.dev/sdk": patch
---
-Refresh package builds for TypeScript 7 compatibility while preserving existing runtime entry points. TypeScript remains an optional peer for the decorator metadata build extension, so installing the Trigger.dev CLI does not install an additional compiler.
+Refresh package builds for TypeScript 7 compatibility while preserving existing runtime entry points. Projects using `emitDecoratorMetadata()` with TypeScript 7 can install the `@typescript/typescript6` compatibility package alongside it; the package remains optional, so installing the Trigger.dev CLI does not install an additional compiler.
diff --git a/docs/config/extensions/emitDecoratorMetadata.mdx b/docs/config/extensions/emitDecoratorMetadata.mdx
index 24317fae5df..ef0aa07d9eb 100644
--- a/docs/config/extensions/emitDecoratorMetadata.mdx
+++ b/docs/config/extensions/emitDecoratorMetadata.mdx
@@ -22,8 +22,31 @@ export default defineConfig({
This is usually required if you are using certain ORMs, like TypeORM, that require this option to be enabled. It's not enabled by default because there is a performance cost to enabling it.
- emitDecoratorMetadata works by hooking into the esbuild bundle process and using the TypeScript
- compiler API to compile files where we detect the use of decorators. This means you must have
- `emitDecoratorMetadata` enabled in your `tsconfig.json` file, as well as `typescript` installed in
- your `devDependencies`.
+ `emitDecoratorMetadata` hooks into the esbuild bundle process and uses the TypeScript compiler API
+ to compile files containing decorators. Enable `emitDecoratorMetadata` in your `tsconfig.json` and
+ install `typescript` in your `devDependencies`.
+
+## Using with TypeScript 7
+
+TypeScript 7 does not expose the JavaScript compiler API required by this extension. Install the TypeScript 6 compatibility package alongside TypeScript 7:
+
+
+
+```bash npm
+npm install --save-dev @typescript/typescript6@latest
+```
+
+```bash pnpm
+pnpm add --save-dev @typescript/typescript6@latest
+```
+
+```bash bun
+bun add --dev @typescript/typescript6@latest
+```
+
+
+
+Your project continues using TypeScript 7 for its normal type checking and compiler commands. The extension loads the compatibility package only when it needs to emit decorator metadata.
+
+Restart the Trigger.dev dev server after installing the package.
diff --git a/packages/build/package.json b/packages/build/package.json
index 5affd1a20c7..9f506b244c0 100644
--- a/packages/build/package.json
+++ b/packages/build/package.json
@@ -89,16 +89,23 @@
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.5",
"@types/resolve": "^1.20.6",
+ "@typescript/typescript6": "6.0.2",
"esbuild": "^0.23.0",
"rimraf": "6.0.1",
"tshy": "^4.1.3",
"tsx": "4.17.0",
- "typescript": "6.0.3"
+ "typescript": "6.0.3",
+ "typescript5": "npm:typescript@5.9.3",
+ "typescript7": "npm:typescript@7.0.2"
},
"peerDependencies": {
+ "@typescript/typescript6": "^6.0.0",
"typescript": ">=5.0.0"
},
"peerDependenciesMeta": {
+ "@typescript/typescript6": {
+ "optional": true
+ },
"typescript": {
"optional": true
}
diff --git a/packages/build/src/extensions/internal/loadTypescript.test.ts b/packages/build/src/extensions/internal/loadTypescript.test.ts
new file mode 100644
index 00000000000..7846681b4b4
--- /dev/null
+++ b/packages/build/src/extensions/internal/loadTypescript.test.ts
@@ -0,0 +1,133 @@
+import { createRequire } from "node:module";
+import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { loadTypescript } from "./loadTypescript.js";
+
+const packageRequire = createRequire(join(process.cwd(), "package.json"));
+const projectDirs = new Set();
+
+function createProject(packages: Record) {
+ const projectDir = mkdtempSync(join(tmpdir(), "trigger-typescript-"));
+ projectDirs.add(projectDir);
+ const nodeModulesDir = join(projectDir, "node_modules");
+
+ mkdirSync(nodeModulesDir);
+ writeFileSync(join(projectDir, "package.json"), JSON.stringify({ private: true }));
+
+ for (const [installedName, sourceName] of Object.entries(packages)) {
+ const target = dirname(packageRequire.resolve(`${sourceName}/package.json`));
+ const destination = join(nodeModulesDir, installedName);
+
+ mkdirSync(dirname(destination), { recursive: true });
+ symlinkSync(target, destination, "junction");
+ }
+
+ return projectDir;
+}
+
+function createBrokenCompiler(projectDir: string, packageName = "typescript") {
+ const packageDir = join(projectDir, "node_modules", packageName);
+
+ mkdirSync(packageDir, { recursive: true });
+ writeFileSync(
+ join(packageDir, "package.json"),
+ JSON.stringify({ name: packageName, main: "index.cjs" })
+ );
+ writeFileSync(join(packageDir, "index.cjs"), 'throw new Error("broken compiler");');
+}
+
+describe("loadTypescript", () => {
+ afterEach(() => {
+ for (const projectDir of projectDirs) {
+ rmSync(projectDir, { recursive: true, force: true });
+ }
+
+ projectDirs.clear();
+ });
+
+ it("loads the consumer's TypeScript 5 compiler", () => {
+ const compiler = loadTypescript(createProject({ typescript: "typescript5" }));
+
+ expect(compiler.version).toMatch(/^5\./);
+ expect(typeof compiler.transpileModule).toBe("function");
+ });
+
+ it("loads the consumer's TypeScript 6 compiler", () => {
+ const compiler = loadTypescript(createProject({ typescript: "typescript" }));
+
+ expect(compiler.version).toMatch(/^6\./);
+ expect(typeof compiler.transpileModule).toBe("function");
+ });
+
+ it("returns an actionable error for TypeScript 7 without the compatibility package", () => {
+ const projectDir = createProject({ typescript: "typescript7" });
+ const requireFromProject = createRequire(join(projectDir, "package.json"));
+
+ expect(typeof requireFromProject("typescript").transpileModule).toBe("undefined");
+ expect(() => loadTypescript(projectDir, ["typescript"])).toThrowError(
+ expect.objectContaining({
+ message: expect.stringContaining("npm install --save-dev @typescript/typescript6"),
+ })
+ );
+ });
+
+ it("surfaces errors from an installed compiler package", () => {
+ const projectDir = createProject({});
+ createBrokenCompiler(projectDir);
+
+ expect(() => loadTypescript(projectDir, ["typescript"])).toThrowError(
+ `Failed to load "typescript" from ${projectDir}.`
+ );
+ });
+
+ it("falls back when an earlier compiler package fails to load", () => {
+ const projectDir = createProject({
+ "@typescript/typescript6": "@typescript/typescript6",
+ });
+ createBrokenCompiler(projectDir);
+
+ const compiler = loadTypescript(projectDir);
+
+ expect(compiler.version).toMatch(/^6\./);
+ expect(typeof compiler.transpileModule).toBe("function");
+ });
+
+ it("falls back to the TypeScript 6 compatibility package for TypeScript 7", () => {
+ const compiler = loadTypescript(
+ createProject({
+ typescript: "typescript7",
+ "@typescript/typescript6": "@typescript/typescript6",
+ })
+ );
+
+ const output = compiler.transpileModule(
+ `
+ class Dependency {}
+ function injectable object>(target: T) {}
+
+ @injectable
+ class Service {
+ constructor(public dependency: Dependency) {}
+ }
+ `,
+ {
+ compilerOptions: {
+ experimentalDecorators: true,
+ emitDecoratorMetadata: true,
+ },
+ }
+ ).outputText;
+
+ expect(compiler.version).toMatch(/^6\./);
+ expect(output).toContain('__metadata("design:paramtypes", [Dependency])');
+ });
+
+ it("supports aliasing TypeScript to the compatibility package", () => {
+ const compiler = loadTypescript(createProject({ typescript: "@typescript/typescript6" }));
+
+ expect(compiler.version).toMatch(/^6\./);
+ expect(typeof compiler.transpileModule).toBe("function");
+ });
+});
diff --git a/packages/build/src/extensions/internal/loadTypescript.ts b/packages/build/src/extensions/internal/loadTypescript.ts
new file mode 100644
index 00000000000..2b187618aca
--- /dev/null
+++ b/packages/build/src/extensions/internal/loadTypescript.ts
@@ -0,0 +1,85 @@
+import { createRequire } from "node:module";
+import { join } from "node:path";
+
+export type TypeScriptCompiler = typeof import("typescript");
+
+const compilerPackages = ["typescript", "@typescript/typescript6"] as const;
+
+function hasTranspileModule(value: unknown): value is TypeScriptCompiler {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ "transpileModule" in value &&
+ typeof value.transpileModule === "function"
+ );
+}
+
+function isUnavailablePackage(error: unknown) {
+ return (
+ error instanceof Error &&
+ "code" in error &&
+ (error.code === "MODULE_NOT_FOUND" || error.code === "ERR_PACKAGE_PATH_NOT_EXPORTED")
+ );
+}
+
+export function loadTypescript(
+ projectDir: string,
+ packageNames: readonly string[] = compilerPackages
+): TypeScriptCompiler {
+ const requireFromProject = createRequire(join(projectDir, "package.json"));
+ const loadErrors: Error[] = [];
+
+ for (const packageName of packageNames) {
+ let resolvedPackage: string;
+
+ try {
+ resolvedPackage = requireFromProject.resolve(packageName);
+ } catch (error) {
+ if (isUnavailablePackage(error)) {
+ continue;
+ }
+
+ throw error;
+ }
+
+ let compiler: unknown;
+
+ try {
+ compiler = requireFromProject(resolvedPackage);
+ } catch (error) {
+ loadErrors.push(
+ new Error(`Failed to load "${packageName}" from ${projectDir}.`, { cause: error })
+ );
+ continue;
+ }
+
+ if (hasTranspileModule(compiler)) {
+ return compiler;
+ }
+ }
+
+ if (loadErrors.length === 1) {
+ throw loadErrors[0];
+ }
+
+ if (loadErrors.length > 1) {
+ throw new AggregateError(
+ loadErrors,
+ `Failed to load a compatible TypeScript compiler from ${projectDir}.`
+ );
+ }
+
+ throw new Error(
+ [
+ "The emitDecoratorMetadata() build extension requires the TypeScript JavaScript compiler API,",
+ "which TypeScript 7 does not expose.",
+ "",
+ "Install the TypeScript 6 compatibility package alongside TypeScript 7:",
+ "",
+ " npm install --save-dev @typescript/typescript6",
+ "",
+ "Restart the Trigger.dev dev server after installing the package.",
+ "See https://trigger.dev/docs/config/extensions/emitDecoratorMetadata#using-with-typescript-7",
+ ].join("\n")
+ );
+}
diff --git a/packages/build/src/extensions/typescript.ts b/packages/build/src/extensions/typescript.ts
index a9e955edecd..1021d4b94a3 100644
--- a/packages/build/src/extensions/typescript.ts
+++ b/packages/build/src/extensions/typescript.ts
@@ -1,8 +1,7 @@
import { BuildExtension } from "@trigger.dev/core/v3/build";
import { readFile } from "node:fs/promises";
-import typescriptPkg from "typescript";
-
-const { transpileModule, ModuleKind } = typescriptPkg;
+import { dirname } from "node:path";
+import { loadTypescript } from "./internal/loadTypescript.js";
const decoratorMatcher = new RegExp(/((?();
build.onLoad({ filter: /\.ts$/ }, async (args) => {
context.logger.debug("emitDecoratorMetadata onLoad", { args });
- const { tsconfigFile, tsconfig } = await parseNative(args.path, {
+ const { tsconfigFile, tsconfig } = await parse(args.path, {
ignoreNodeModules: true,
cache,
});
+ const { options: compilerOptions } = convertCompilerOptionsFromJson(
+ tsconfig.compilerOptions ?? {},
+ tsconfigFile ? dirname(tsconfigFile) : context.workingDir
+ );
- context.logger.debug("emitDecoratorMetadata parsed native tsconfig", {
+ context.logger.debug("emitDecoratorMetadata parsed tsconfig", {
tsconfig,
tsconfigFile,
args,
});
- if (tsconfig.compilerOptions?.emitDecoratorMetadata !== true) {
+ if (compilerOptions.emitDecoratorMetadata !== true) {
context.logger.debug("emitDecoratorMetadata skipping", {
args,
tsconfig,
@@ -55,7 +62,7 @@ export function emitDecoratorMetadata(): BuildExtension {
const program = transpileModule(ts, {
fileName: args.path,
compilerOptions: {
- ...tsconfig.compilerOptions,
+ ...compilerOptions,
module: ModuleKind.ES2022,
},
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c908181e766..86ed8090133 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1513,6 +1513,9 @@ importers:
'@types/resolve':
specifier: ^1.20.6
version: 1.20.6
+ '@typescript/typescript6':
+ specifier: 6.0.2
+ version: 6.0.2
esbuild:
specifier: ^0.23.0
version: 0.23.0
@@ -1528,6 +1531,12 @@ importers:
typescript:
specifier: 6.0.3
version: 6.0.3
+ typescript5:
+ specifier: npm:typescript@5.9.3
+ version: typescript@5.9.3
+ typescript7:
+ specifier: npm:typescript@7.0.2
+ version: typescript@7.0.2
packages/cli-v3:
dependencies:
@@ -15355,6 +15364,11 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
typescript@6.0.3:
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
engines: {node: '>=14.17'}
@@ -31587,6 +31601,8 @@ snapshots:
typescript@5.6.1-rc: {}
+ typescript@5.9.3: {}
+
typescript@6.0.3: {}
typescript@7.0.2: