Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add support for TypeScript config files #117

Open
wants to merge 23 commits into
base: main
Choose a base branch
from

Conversation

aryaemami59
Copy link

Summary

Add support for TypeScript config files (eslint.config.ts, eslint.config.mts, eslint.config.cts).

Related Issues

This PR is related to this RFC.

@aryaemami59 aryaemami59 marked this pull request as ready for review March 9, 2024 18:15
@bradzacher
Copy link

Worth linking the prior art in this area: #50

@kecrily
Copy link
Member

kecrily commented Mar 10, 2024

I think it makes sense and is low cost to support this in a runtime that supports ts natively. Like deno and bun, we can just import them.

@aryaemami59
Copy link
Author

@bradzacher Yes thank you!!!

designs/2024-support-ts-config-files/README.md Outdated Show resolved Hide resolved
designs/2024-support-ts-config-files/README.md Outdated Show resolved Hide resolved

The primary motivation for adding support for TypeScript configuration files to ESLint is to enhance the developer experience and accommodate the evolving JavaScript ecosystem. As TypeScript's popularity continues to grow, more projects are adopting TypeScript not only for their source code but also for their configuration files. This shift is driven by TypeScript's ability to provide compile-time type checks and IntelliSense. By supporting `eslint.config.ts`, `eslint.config.mts`, and `eslint.config.cts`, ESLint will offer first-class support to TypeScript users, allowing them to leverage these benefits directly within their ESLint configuration.

## Detailed Design
Copy link
Sponsor Member

@bmish bmish Mar 12, 2024

Choose a reason for hiding this comment

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

How does the feature interact with the CLI option --config for specifying a config file?

Copy link
Author

Choose a reason for hiding this comment

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

I have tested it, so far it seems to work pretty well actually, especially with v9. I'm probably going to write a bunch of tests as well to see if there are any edge cases but so far so good.

Copy link
Sponsor Member

Choose a reason for hiding this comment

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

It would be good to explain what the behavior is when specifying TS or non-TS config files of varying file extensions through that option.

Copy link
Author

Choose a reason for hiding this comment

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

It doesn't behave any differently, same as before. You can do eslint . --config=eslint.config.ts or eslint . -c eslint.config.ts and they just work. Same as with a eslint.config.js file.

Copy link
Member

Choose a reason for hiding this comment

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

Can you add that into the RFC?

Copy link
Author

Choose a reason for hiding this comment

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

I added it to the open questions, is that fine?

@ljharb
Copy link
Sponsor

ljharb commented Mar 13, 2024

Which version of TypeScript can the config file be written in? With what tsconfig settings?

Does this mean eslint would need to depend on typescript, causing it to be installed for non-TS users? if not, then would eslint be able to consume typescript in pnpm and yarn pnp in order to transpile the eslint config?

@bradzacher
Copy link

if not, then would eslint be able to consume typescript in pnpm and yarn pnp in order to transpile the eslint config?

It's possible to declare an implicit, optional peer dependency in a way that both yarn and pnpm will respect.

{
  "peerDependenciesMeta": {
    "typescript": {
      "optional": true
    }
  },
}

You don't even need to declare an explicit peer dependency with this config as it implicitly declares a dep on typescript: "*".

For context this is how the @typescript-eslint packages depend on TypeScript.

@aryaemami59
Copy link
Author

@ljharb

Which version of TypeScript can the config file be written in?

We are going to be using jiti to transpile the TypeScript config files. It has been used by frameworks such as Nuxt, Tailwind CSS and Docusaurus for this exact purpose. As far as I'm aware of, the only limitation it has, is that it doesn't yet support top-level await. The latest TypeScript syntax that I can think of that would also be relevant to our situation is the satisfies operator which I have made sure jiti has no problem with as I used it recently to do the exact same thing with size-limit.

With what tsconfig settings?

As far as I know, jiti does not care about tsconfig settings. It doesn't work like ts-node. I believe it uses babel internally to do the transpilling and it provides an interopDefault option to allow for using either export default and module.exports which in this situation would be very helpful as users will not have to worry about ESM/CJS compatibility issues.

Does this mean eslint would need to depend on typescript, causing it to be installed for non-TS users? if not, then would eslint be able to consume typescript in pnpm and yarn pnp in order to transpile the eslint config?

I think jiti will have to become either a dependency or an optional dependency. And we could make TypeScript an optional peer dependency as to not force the non-TS users to install it.

@mdjermanovic mdjermanovic added the Initial Commenting This RFC is in the initial feedback stage label Mar 19, 2024
@aryaemami59
Copy link
Author

Thanks for this RFC @aryaemami59! Could it be that Jiti doesn't transpile top-level await expressions properly?
Would it be possible to add a note to the RFC to state this limitation - or if I'm overlooking something, explain how to make top-level await work?

@fasttime Yeah I mentioned it in this comment but I can go ahead and include it in the RFC as well. To my knowledge top level awaits are pretty much the only limitation jiti has. The library author did hint at a jiti.import method but it doesn't seem to be ready yet.

@fasttime
Copy link
Member

fasttime commented Apr 4, 2024

@fasttime Yeah I mentioned it in this comment but I can go ahead and include it in the RFC as well. To my knowledge top level awaits are pretty much the only limitation jiti has. The library author did hint at a jiti.import method but it doesn't seem to be ready yet.

Yes, the lack of support for top-level await should be included in the RFC since it is an important limitation. If the RFC is accepted, we will also need to include it as a note in the documentation, along with other inconsistencies we may find while adding unit tests.

This section should also include prior art, such as whether similar
projects have already implemented a similar feature.
-->

Copy link
Member

Choose a reason for hiding this comment

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

there is a community project: https://github.com/antfu/eslint-ts-patch to support it. would like to hear the author :) / @antfu

Copy link

Choose a reason for hiding this comment

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

It uses jiti to support ts file. Which already mentioned #117 (comment) that it doesn't currently support top-level await.

I would personally recommend using https://github.com/egoist/bundle-require instead which is more robust and will respect tsconfig.json. The downside is that it would introduce esbuild into the dependency. If the install size is a concern, I guess we could have an optional package like @eslint/config-loader-ts that only requires when users use ts version of config.

Copy link

Choose a reason for hiding this comment

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

Looks like @eslint/config-inspector uses bundle-require as well.

Copy link

Choose a reason for hiding this comment

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

Yes, the inspector uses that because we need to know the dependencies of the config to do automatic reloads. Supporting TS was a free side-effect.

Even if ESLint doesn't need information on dependencies, I think it's still a good way to support TS. Vite uses the same approach to load vite.config.ts.

Copy link

Choose a reason for hiding this comment

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

Does bundle-require write a temp file to disk like vite does?

These temp files are a major source of problems with vite, see vitejs/vite#9470.

Copy link
Member

Choose a reason for hiding this comment

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

Jiti won't recognize TLA not only in the config file itself, but also in imported modules, because they are all treated as CommonJS. I did a quick test using a patched version of ESLint with the changes from #18134 and with this config:

// eslint.config.ts
export { default } from './recommended.mjs';
// recommended.mjs
import { readFile } from 'node:fs/promises';

const json = await readFile('package.json', 'utf-8');
const { name } = JSON.parse(json);

export default [{ name, rules: { 'no-undef': 'error' } }];

When running eslint, I got an error as expected:

Oops! Something went wrong! :(

ESLint: 9.2.0

ReferenceError: await is not defined
    at ../project/recommended.mjs:4:14
    at evalModule (../project/node_modules/eslint/node_modules/jiti/dist/jiti.js:1:256443)
    at jiti (../project/node_modules/eslint/node_modules/jiti/dist/jiti.js:1:254371)
    at ../project/eslint.config.ts:2:43
    at evalModule (../project/node_modules/eslint/node_modules/jiti/dist/jiti.js:1:256443)
    at jiti (../project/node_modules/eslint/node_modules/jiti/dist/jiti.js:1:254371)
    at loadFlatConfigFile (../project/node_modules/eslint/lib/eslint/eslint.js:335:24)
    at async calculateConfigArray (../project/node_modules/eslint/lib/eslint/eslint.js:421:28)
    at async ESLint.lintFiles (../project/node_modules/eslint/lib/eslint/eslint.js:840:25)
    at async Object.execute (../project/node_modules/eslint/lib/cli.js:500:23)
    at async main (../project/node_modules/eslint/bin/eslint.js:153:22)

The workaround of using a dynamic import didn't help either, and resulted in the same error:

// eslint.config.ts
export default (async () =>
    (await import('./recommended.mjs')).default
)();

This means that if we decide to use Jiti, plugin developers should be advised to avoid TLA in their shared configs (including rules and transitive dependencies), or else those configs will not work for users who have a eslint.config.ts in their project.

Copy link

Choose a reason for hiding this comment

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

I added support for all the ts loaders we mentioned in eslint-ts-patch and listed their trade-offs, where you can give them a try today.

Copy link
Member

Choose a reason for hiding this comment

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

@privatenumber I tried to use tsx tsImport as explained here to load a .ts config file, but didn't succeed. This is my code:

let config;
const { tsImport, register } = require("tsx/esm/api");
const unregister = register();
try {
    config = (await tsImport(fileURL.href, __filename)).default;
} finally {
    unregister();
}

where fileURL is the URL of this config file:

// eslint.config.mts
export default [];

The error stack trace indicates that the config file is being loaded as CommonJS:

SyntaxError: Unexpected token 'export'
    at wrapSafe (node:internal/modules/cjs/loader:1389:18)
    at Module._compile (node:internal/modules/cjs/loader:1425:20)
    at Module._extensions..js (node:internal/modules/cjs/loader:1564:10)
    at Module.load (node:internal/modules/cjs/loader:1287:32)
    at Module._load (node:internal/modules/cjs/loader:1103:12)
    at cjsLoader (node:internal/modules/esm/translators:318:15)
    at ModuleWrap.<anonymous> (node:internal/modules/esm/translators:258:7)
    at ModuleJob.run (node:internal/modules/esm/module_job:262:25)
    at async ModuleLoader.import (node:internal/modules/esm/loader:474:24)

I guess I'm doing something wrong?

Copy link
Sponsor

Choose a reason for hiding this comment

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

This is probably not the best place to discuss this. Can you send me a reproduction link in the tsx repo?

Copy link
Member

Choose a reason for hiding this comment

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

This is probably not the best place to discuss this. Can you send me a reproduction link in the tsx repo?

Thanks! I think the problem is that I wasn't calling register from require("tsx/cjs/api"). It works if I add that call before tsImport. Another problem is that there is seemingly no way to completely undo the changes done by tsImport to the Node.js loader internals. Particularly, I think there is no way to unregister the ESM Module loader. I didn't even find out how to do that programmatically, so this is possibly a limitation of Node.js.

nzakas
nzakas previously approved these changes May 6, 2024
Copy link
Member

@nzakas nzakas left a comment

Choose a reason for hiding this comment

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

I think the jiti approach makes sense to me. It's fairly clean and even though it doesn't support top-level await, I think we can live with that.

Copy link
Member

@aladdin-add aladdin-add left a comment

Choose a reason for hiding this comment

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

I'm conflicted about this requirement. While it may burden non-TypeScript users with an unnecessary dependency, TypeScript users are a significant part of the ESLint community. I'll defer to the team's judgment on this one.


3. Using [TypeScript's `transpileModule()`](https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API#a-simple-transform-function) to parse TypeScript configuration files. This approach proved to be problematic because it requires a significant amount of overhead and is not suitable for this purpose.

## Open Questions
Copy link
Member

Choose a reason for hiding this comment

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

When using the ts configs, does it check the ts type, or is it just erasure typings?

projects have already implemented a similar feature.
-->

While developing this feature, we considered the following alternatives:
Copy link
Member

Choose a reason for hiding this comment

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

A feasible alternative is leaving it to the community, e.g. eslint-ts-patch. ts users just need to install it, and it just works.

@privatenumber
Copy link
Sponsor

I agree. As a user, I wouldn't want a TS compiler installed in non-TS projects, and I wouldn't want a second TS compiler installed if I already have one installed.

As a reference in postcss-load-config, tsx and jiti are optional peer dependencies so it's up to the user to provide:
https://github.com/postcss/postcss-load-config/blob/62d325c7c51fa536c433b2a2517e59d8f1ed101d/src/req.js#L18-L57

@nzakas
Copy link
Member

nzakas commented May 7, 2024

@privatenumber I think that's good feedback. We could definitely specify TS-related stuff as an optional dependency and ask folks to manually install.

@silverwind
Copy link

silverwind commented May 8, 2024

Seems like a good call to require opt-in to these dependencies. Some runtimes like bun and deno support typescript out of the box so won't need this compiler. I think it's also likely that Node.js will gain native typescript support eventually, see nodejs/node#43816.

@nzakas
Copy link
Member

nzakas commented May 8, 2024

Some runtimes like bun and deno support typescript out of the box so won't need this compiler.

That's true. In those runtimes the .ts file would just be loaded without any other dependencies.

@kecrily
Copy link
Member

kecrily commented May 8, 2024

This is consistent with what I mentioned in #117 (comment). We can check the runtime and import it directly, otherwise keep looking for the ts loader

- There should not be extra overhead for JavaScript users. This means this change should not have a significant impact (if any at all) affecting users who use plain JavaScript config files.
- The external tools that are used to parse the config files written in TypeScript should not create side effects. Specifically, it is imperative that these tools do not interfere with Node.js's native module resolution system by hooking into or altering the standard `import/require` mechanisms. This means tools like [`ts-node`](https://github.com/TypeStrong/ts-node) and [`tsx`](https://github.com/privatenumber/tsx) might not be suitable for this purpose.

So far the tool that seems to be the most suitable for this purpose is [`jiti`](https://www.npmjs.com/package/jiti). It does not introduce side effects and performs well, demonstrating its reliability. It also seems to be more battle-tested given some established frameworks such as [Nuxt](https://github.com/nuxt/nuxt), [Tailwind CSS](https://github.com/tailwindlabs/tailwindcss) and [Docusaurus](https://github.com/facebook/docusaurus) have been using it to load their configuration files.
Copy link

@antfu antfu May 10, 2024

Choose a reason for hiding this comment

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

I would like to propose using tsx instead of jiti. Implementation PR is here: eslint/eslint#18440

While being a Nuxt team member and a heavy jiti user, I'd be really happy to see jiti being used. However, I'd say that jiti's current approach is not honestly future-proof. It uses a built-in babel parser to transpile TS and ESM code and evaluate them in CJS mode, which means it does not support top-level await, and could have some misalignment with ESM. While there is a plan to support full ESM mode, the implementation isn't easy and I would not expect it to be landed very soon (I tried to make it happend a few times but didn't work out)

On the other hand, tsx's recent tsx/esm/api seems like a much better approach as it uses Node's native loader API, which means the module resolutions and evaluation is in native Node, which would have a much more smaller interface of potential misalignment.

I expiremented three different loader approaches in eslint-ts-patch, and so far I see the tsx the most solid approach. The only downside is that it requires minimal Node.js v20.8.0 and v18.19.0, but I suppose it won't be an issue very soon as the ecosystem moving forward.

@Bluzzi
Copy link

Bluzzi commented May 10, 2024

In the case of using alternative runtimes that support TS by default (Bun, Deno), which solution will prevail between:

  • the native support of the runtime
  • the solution implemented in ESLint to support eslint.config.ts

Or is it possible that the implementation of this support could conflict with these runtimes and prevent their use or impair their proper functioning?

@antfu
Copy link

antfu commented May 10, 2024

@Bluzzi I guess we could try catch native importing a tiny ts file to see if it's supported? Ideally it should work with ts-node and tsx's CLI where they registered global loader of Node as well - I will try to put up a tiny library for that maybe

@aryaemami59
Copy link
Author

I'm really glad this is getting the traction it needs, I was gone for less than a week, and already we have a counter proposal! Now here is something we could do. I'm actually really liking what @antfu has done with eslint-ts-patch by adding support for all the TS loaders we've discussed so far. So what we can do (or rather what I'm probably going to do) is create a giant testing matrix to figure out which one is going to cover the most ground.

Keep in mind when I made the initial proposal tsx did not yet have a tsImport API. So jiti seemed like it would be more suited for something like this. Though I still think jiti is probably the way to go, @antfu is correct in that because jiti handles everything synchronously (uses babel to transpile to CJS), enabling support for top-level-await is going to be difficult. Another approach we could consider is what postcss-load-config has done with peerDependencies like @privatenumber previously mentioned. And while bundle-require does write tmp files to disk, it uses esbuild which is quite fast and efficient. So it's still a viable option. Not to mention bundle-require is written by @egoist who is also the author of tsup which is somewhat relevant to what we're trying to do here.

@antfu
Copy link

antfu commented May 11, 2024

I ended up creating a wrapper library importx, which supports all the solutions we mention and will smartly decide one based on the user env or allow manual swap the loader if one doesn't work out. In runtime that supports importing TS directly (Deno/Bun), it will use native imports for the best performance (as @Bluzzi mentioned). Also has a matrix here

If we don't want to be coupled with one solution and its limitation, I'd say we could probably use importx to manage the underlying implementation and swallow the complexity, so ESLint no longer needs to concern it.

@aladdin-add
Copy link
Member

@antfu I like it! 👍

@kecrily
Copy link
Member

kecrily commented May 11, 2024

importx looks good. But based on the discussion above we seem to prefer letting the user install the optional loader themselves.

@antfu
Copy link

antfu commented May 11, 2024

Yes, we could make importx optional and let users install it explicitly. Same as how I implement and documented as eslint/eslint#18440

designs/2024-support-ts-config-files/README.md Outdated Show resolved Hide resolved
designs/2024-support-ts-config-files/README.md Outdated Show resolved Hide resolved
designs/2024-support-ts-config-files/README.md Outdated Show resolved Hide resolved
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature Initial Commenting This RFC is in the initial feedback stage
Projects
None yet