Skip to content

Commit 1a16d61

Browse files
authored
fix(build): support decorator metadata with TypeScript 7 (#4505)
## Summary Allow projects using TypeScript 7 to enable `emitDecoratorMetadata()` without adding the TypeScript 6 compiler to every Trigger.dev CLI installation. Addresses #4500. ## Fix The extension now resolves TypeScript from the project and feature-detects the legacy compiler API. TypeScript 5 and 6 continue using the project's compiler, while TypeScript 7 projects can install Microsoft's optional `@typescript/typescript6` compatibility package alongside TypeScript 7. When no compatible compiler API is available, the build reports an actionable installation error. The extension documentation includes setup commands for npm, pnpm, and Bun. Verified with TypeScript 5, TypeScript 6, TypeScript 7 with and without the compatibility package, emitted decorator metadata, packed ESM and CommonJS consumers, package export checks, and typechecking.
1 parent 771937a commit 1a16d61

7 files changed

Lines changed: 285 additions & 14 deletions

File tree

.changeset/typescript-seven-builds.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@
1010
"@trigger.dev/sdk": patch
1111
---
1212

13-
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.
13+
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.

docs/config/extensions/emitDecoratorMetadata.mdx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,31 @@ export default defineConfig({
2222
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.
2323

2424
<Note>
25-
emitDecoratorMetadata works by hooking into the esbuild bundle process and using the TypeScript
26-
compiler API to compile files where we detect the use of decorators. This means you must have
27-
`emitDecoratorMetadata` enabled in your `tsconfig.json` file, as well as `typescript` installed in
28-
your `devDependencies`.
25+
`emitDecoratorMetadata` hooks into the esbuild bundle process and uses the TypeScript compiler API
26+
to compile files containing decorators. Enable `emitDecoratorMetadata` in your `tsconfig.json` and
27+
install `typescript` in your `devDependencies`.
2928
</Note>
29+
30+
## Using with TypeScript 7
31+
32+
TypeScript 7 does not expose the JavaScript compiler API required by this extension. Install the TypeScript 6 compatibility package alongside TypeScript 7:
33+
34+
<CodeGroup>
35+
36+
```bash npm
37+
npm install --save-dev @typescript/typescript6@latest
38+
```
39+
40+
```bash pnpm
41+
pnpm add --save-dev @typescript/typescript6@latest
42+
```
43+
44+
```bash bun
45+
bun add --dev @typescript/typescript6@latest
46+
```
47+
48+
</CodeGroup>
49+
50+
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.
51+
52+
Restart the Trigger.dev dev server after installing the package.

packages/build/package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,16 +89,23 @@
8989
"devDependencies": {
9090
"@arethetypeswrong/cli": "^0.18.5",
9191
"@types/resolve": "^1.20.6",
92+
"@typescript/typescript6": "6.0.2",
9293
"esbuild": "^0.23.0",
9394
"rimraf": "6.0.1",
9495
"tshy": "^4.1.3",
9596
"tsx": "4.17.0",
96-
"typescript": "6.0.3"
97+
"typescript": "6.0.3",
98+
"typescript5": "npm:typescript@5.9.3",
99+
"typescript7": "npm:typescript@7.0.2"
97100
},
98101
"peerDependencies": {
102+
"@typescript/typescript6": "^6.0.0",
99103
"typescript": ">=5.0.0"
100104
},
101105
"peerDependenciesMeta": {
106+
"@typescript/typescript6": {
107+
"optional": true
108+
},
102109
"typescript": {
103110
"optional": true
104111
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { createRequire } from "node:module";
2+
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { dirname, join } from "node:path";
5+
import { afterEach, describe, expect, it } from "vitest";
6+
import { loadTypescript } from "./loadTypescript.js";
7+
8+
const packageRequire = createRequire(join(process.cwd(), "package.json"));
9+
const projectDirs = new Set<string>();
10+
11+
function createProject(packages: Record<string, string>) {
12+
const projectDir = mkdtempSync(join(tmpdir(), "trigger-typescript-"));
13+
projectDirs.add(projectDir);
14+
const nodeModulesDir = join(projectDir, "node_modules");
15+
16+
mkdirSync(nodeModulesDir);
17+
writeFileSync(join(projectDir, "package.json"), JSON.stringify({ private: true }));
18+
19+
for (const [installedName, sourceName] of Object.entries(packages)) {
20+
const target = dirname(packageRequire.resolve(`${sourceName}/package.json`));
21+
const destination = join(nodeModulesDir, installedName);
22+
23+
mkdirSync(dirname(destination), { recursive: true });
24+
symlinkSync(target, destination, "junction");
25+
}
26+
27+
return projectDir;
28+
}
29+
30+
function createBrokenCompiler(projectDir: string, packageName = "typescript") {
31+
const packageDir = join(projectDir, "node_modules", packageName);
32+
33+
mkdirSync(packageDir, { recursive: true });
34+
writeFileSync(
35+
join(packageDir, "package.json"),
36+
JSON.stringify({ name: packageName, main: "index.cjs" })
37+
);
38+
writeFileSync(join(packageDir, "index.cjs"), 'throw new Error("broken compiler");');
39+
}
40+
41+
describe("loadTypescript", () => {
42+
afterEach(() => {
43+
for (const projectDir of projectDirs) {
44+
rmSync(projectDir, { recursive: true, force: true });
45+
}
46+
47+
projectDirs.clear();
48+
});
49+
50+
it("loads the consumer's TypeScript 5 compiler", () => {
51+
const compiler = loadTypescript(createProject({ typescript: "typescript5" }));
52+
53+
expect(compiler.version).toMatch(/^5\./);
54+
expect(typeof compiler.transpileModule).toBe("function");
55+
});
56+
57+
it("loads the consumer's TypeScript 6 compiler", () => {
58+
const compiler = loadTypescript(createProject({ typescript: "typescript" }));
59+
60+
expect(compiler.version).toMatch(/^6\./);
61+
expect(typeof compiler.transpileModule).toBe("function");
62+
});
63+
64+
it("returns an actionable error for TypeScript 7 without the compatibility package", () => {
65+
const projectDir = createProject({ typescript: "typescript7" });
66+
const requireFromProject = createRequire(join(projectDir, "package.json"));
67+
68+
expect(typeof requireFromProject("typescript").transpileModule).toBe("undefined");
69+
expect(() => loadTypescript(projectDir, ["typescript"])).toThrowError(
70+
expect.objectContaining({
71+
message: expect.stringContaining("npm install --save-dev @typescript/typescript6"),
72+
})
73+
);
74+
});
75+
76+
it("surfaces errors from an installed compiler package", () => {
77+
const projectDir = createProject({});
78+
createBrokenCompiler(projectDir);
79+
80+
expect(() => loadTypescript(projectDir, ["typescript"])).toThrowError(
81+
`Failed to load "typescript" from ${projectDir}.`
82+
);
83+
});
84+
85+
it("falls back when an earlier compiler package fails to load", () => {
86+
const projectDir = createProject({
87+
"@typescript/typescript6": "@typescript/typescript6",
88+
});
89+
createBrokenCompiler(projectDir);
90+
91+
const compiler = loadTypescript(projectDir);
92+
93+
expect(compiler.version).toMatch(/^6\./);
94+
expect(typeof compiler.transpileModule).toBe("function");
95+
});
96+
97+
it("falls back to the TypeScript 6 compatibility package for TypeScript 7", () => {
98+
const compiler = loadTypescript(
99+
createProject({
100+
typescript: "typescript7",
101+
"@typescript/typescript6": "@typescript/typescript6",
102+
})
103+
);
104+
105+
const output = compiler.transpileModule(
106+
`
107+
class Dependency {}
108+
function injectable<T extends new (...args: any[]) => object>(target: T) {}
109+
110+
@injectable
111+
class Service {
112+
constructor(public dependency: Dependency) {}
113+
}
114+
`,
115+
{
116+
compilerOptions: {
117+
experimentalDecorators: true,
118+
emitDecoratorMetadata: true,
119+
},
120+
}
121+
).outputText;
122+
123+
expect(compiler.version).toMatch(/^6\./);
124+
expect(output).toContain('__metadata("design:paramtypes", [Dependency])');
125+
});
126+
127+
it("supports aliasing TypeScript to the compatibility package", () => {
128+
const compiler = loadTypescript(createProject({ typescript: "@typescript/typescript6" }));
129+
130+
expect(compiler.version).toMatch(/^6\./);
131+
expect(typeof compiler.transpileModule).toBe("function");
132+
});
133+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { createRequire } from "node:module";
2+
import { join } from "node:path";
3+
4+
export type TypeScriptCompiler = typeof import("typescript");
5+
6+
const compilerPackages = ["typescript", "@typescript/typescript6"] as const;
7+
8+
function hasTranspileModule(value: unknown): value is TypeScriptCompiler {
9+
return (
10+
typeof value === "object" &&
11+
value !== null &&
12+
"transpileModule" in value &&
13+
typeof value.transpileModule === "function"
14+
);
15+
}
16+
17+
function isUnavailablePackage(error: unknown) {
18+
return (
19+
error instanceof Error &&
20+
"code" in error &&
21+
(error.code === "MODULE_NOT_FOUND" || error.code === "ERR_PACKAGE_PATH_NOT_EXPORTED")
22+
);
23+
}
24+
25+
export function loadTypescript(
26+
projectDir: string,
27+
packageNames: readonly string[] = compilerPackages
28+
): TypeScriptCompiler {
29+
const requireFromProject = createRequire(join(projectDir, "package.json"));
30+
const loadErrors: Error[] = [];
31+
32+
for (const packageName of packageNames) {
33+
let resolvedPackage: string;
34+
35+
try {
36+
resolvedPackage = requireFromProject.resolve(packageName);
37+
} catch (error) {
38+
if (isUnavailablePackage(error)) {
39+
continue;
40+
}
41+
42+
throw error;
43+
}
44+
45+
let compiler: unknown;
46+
47+
try {
48+
compiler = requireFromProject(resolvedPackage);
49+
} catch (error) {
50+
loadErrors.push(
51+
new Error(`Failed to load "${packageName}" from ${projectDir}.`, { cause: error })
52+
);
53+
continue;
54+
}
55+
56+
if (hasTranspileModule(compiler)) {
57+
return compiler;
58+
}
59+
}
60+
61+
if (loadErrors.length === 1) {
62+
throw loadErrors[0];
63+
}
64+
65+
if (loadErrors.length > 1) {
66+
throw new AggregateError(
67+
loadErrors,
68+
`Failed to load a compatible TypeScript compiler from ${projectDir}.`
69+
);
70+
}
71+
72+
throw new Error(
73+
[
74+
"The emitDecoratorMetadata() build extension requires the TypeScript JavaScript compiler API,",
75+
"which TypeScript 7 does not expose.",
76+
"",
77+
"Install the TypeScript 6 compatibility package alongside TypeScript 7:",
78+
"",
79+
" npm install --save-dev @typescript/typescript6",
80+
"",
81+
"Restart the Trigger.dev dev server after installing the package.",
82+
"See https://trigger.dev/docs/config/extensions/emitDecoratorMetadata#using-with-typescript-7",
83+
].join("\n")
84+
);
85+
}

packages/build/src/extensions/typescript.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,43 @@
11
import { BuildExtension } from "@trigger.dev/core/v3/build";
22
import { readFile } from "node:fs/promises";
3-
import typescriptPkg from "typescript";
4-
5-
const { transpileModule, ModuleKind } = typescriptPkg;
3+
import { dirname } from "node:path";
4+
import { loadTypescript } from "./internal/loadTypescript.js";
65

76
const decoratorMatcher = new RegExp(/((?<![(\s]\s*['"])@\w[.[\]\w\d]*\s*(?![;])[((?=\s)])/);
87

98
export function emitDecoratorMetadata(): BuildExtension {
109
return {
1110
name: "emitDecoratorMetadata",
1211
onBuildStart(context) {
12+
const { convertCompilerOptionsFromJson, transpileModule, ModuleKind } = loadTypescript(
13+
context.workingDir
14+
);
15+
1316
context.registerPlugin({
1417
name: "emitDecoratorMetadata",
1518
async setup(build) {
16-
const { parseNative, TSConfckCache } = await import("tsconfck");
19+
const { parse, TSConfckCache } = await import("tsconfck");
1720
const cache = new TSConfckCache<any>();
1821

1922
build.onLoad({ filter: /\.ts$/ }, async (args) => {
2023
context.logger.debug("emitDecoratorMetadata onLoad", { args });
2124

22-
const { tsconfigFile, tsconfig } = await parseNative(args.path, {
25+
const { tsconfigFile, tsconfig } = await parse(args.path, {
2326
ignoreNodeModules: true,
2427
cache,
2528
});
29+
const { options: compilerOptions } = convertCompilerOptionsFromJson(
30+
tsconfig.compilerOptions ?? {},
31+
tsconfigFile ? dirname(tsconfigFile) : context.workingDir
32+
);
2633

27-
context.logger.debug("emitDecoratorMetadata parsed native tsconfig", {
34+
context.logger.debug("emitDecoratorMetadata parsed tsconfig", {
2835
tsconfig,
2936
tsconfigFile,
3037
args,
3138
});
3239

33-
if (tsconfig.compilerOptions?.emitDecoratorMetadata !== true) {
40+
if (compilerOptions.emitDecoratorMetadata !== true) {
3441
context.logger.debug("emitDecoratorMetadata skipping", {
3542
args,
3643
tsconfig,
@@ -55,7 +62,7 @@ export function emitDecoratorMetadata(): BuildExtension {
5562
const program = transpileModule(ts, {
5663
fileName: args.path,
5764
compilerOptions: {
58-
...tsconfig.compilerOptions,
65+
...compilerOptions,
5966
module: ModuleKind.ES2022,
6067
},
6168
});

pnpm-lock.yaml

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)