Prerequisites
Reproduction
No StackBlitz — the reproduction is a fixture in this repo, which seemed more direct. Two fixtures, identical except for one async keyword.
fixtures/compilers/promise-sync/:
// knip.ts
export default { compilers: { foo: (text: string) => Promise.resolve(text) } };
// index.ts
import './styles.foo';
// styles.foo
import _$0 from 'dep';
fixtures/compilers/promise-async/ is the same with foo: async (text: string) => text.
| compiler |
result |
async (text) => text |
dep correctly seen as used — 0 issues |
(text) => Promise.resolve(text) |
dep reported as an unused dependency |
Description of the issue
A compiler that returns a Promise without being declared async is classified as sync, and its Promise object ends up cached as the file's source text.
isAsyncCompiler only checks the function constructor:
// src/compilers/index.ts:17
// TODO This does not detect functions returning a promise (just the async keyword)
const isAsyncCompiler = (fn?: CompilerSync | CompilerAsync) =>
fn ? fn.constructor.name === 'AsyncFunction' : false;
so partitionCompilers routes it into syncCompilers, and SourceFileManager.readFile calls it without awaiting:
// src/typescript/SourceFileManager.ts:37-39
const compiled = compiler ? compiler(contents, filePath) : contents;
this.sourceTextCache.set(filePath, compiled);
TypeScript then receives a Promise object as file contents. Every import in that file disappears and the dependencies it referenced are reported unused — no error, no warning.
What makes this easy to walk into is that the public type allows both forms:
// src/compilers/types.ts:4
export type CompilerAsync = (source: string, path: string) => Promise<string>;
TypeScript doesn't distinguish async (t) => t from (t) => Promise.resolve(t), so a correctly typed compiler silently breaks depending on how it's written. Delegating compilers like (text) => transpiler.transform(text) land on the broken side.
I see three directions and didn't want to guess:
- Detect a thenable on first call and reclassify the extension as async — correct in all cases, but
readFile is synchronous by design, so this is the invasive one.
- Throw a clear error from
readFile when the compiler returns a non-string: "compiler for .foo returned a Promise; declare it async or use asyncCompilers". Doesn't fix it, but turns a silent false positive into something actionable. My preference.
- Document the limitation on the compilers page.
asyncCompilers is of course a valid workaround, but compilers advertises auto-detection and fails quietly here.
Happy to send a PR with the fixture and a regression test once you've picked a direction.
Prerequisites
main, v6.29.0)Reproduction
No StackBlitz — the reproduction is a fixture in this repo, which seemed more direct. Two fixtures, identical except for one
asynckeyword.fixtures/compilers/promise-sync/:fixtures/compilers/promise-async/is the same withfoo: async (text: string) => text.async (text) => textdepcorrectly seen as used — 0 issues(text) => Promise.resolve(text)depreported as an unused dependencyDescription of the issue
A compiler that returns a Promise without being declared
asyncis classified as sync, and its Promise object ends up cached as the file's source text.isAsyncCompileronly checks the function constructor:so
partitionCompilersroutes it intosyncCompilers, andSourceFileManager.readFilecalls it without awaiting:TypeScript then receives a
Promiseobject as file contents. Every import in that file disappears and the dependencies it referenced are reported unused — no error, no warning.What makes this easy to walk into is that the public type allows both forms:
TypeScript doesn't distinguish
async (t) => tfrom(t) => Promise.resolve(t), so a correctly typed compiler silently breaks depending on how it's written. Delegating compilers like(text) => transpiler.transform(text)land on the broken side.I see three directions and didn't want to guess:
readFileis synchronous by design, so this is the invasive one.readFilewhen the compiler returns a non-string: "compiler for.fooreturned a Promise; declare itasyncor useasyncCompilers". Doesn't fix it, but turns a silent false positive into something actionable. My preference.asyncCompilersis of course a valid workaround, butcompilersadvertises auto-detection and fails quietly here.Happy to send a PR with the fixture and a regression test once you've picked a direction.