microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.7k stars 12.45k forks source link

IsolatedDeclarations: emitted declarations inconsistent between `transpileDeclaration` API and TypeScript Playground #60031

Open Dunqing opened 3 weeks ago

Dunqing commented 3 weeks ago

Acknowledgement

Example

NOTE: Enabled isolatedDeclaration and noCheck

Playground Link

// input
import { Comment } from '@vue/runtime-core'
//       ^^^^^^^ https://github.com/vuejs/core/blob/a177092754642af2f98c33a4feffe8f198c3c950/packages/runtime-core/src/vnode.ts#L70
export const comment: Comment 

// .d.ts output
export declare const comment: Comment;

This example is correct because the tsc checker analyzer Comment is a const variable defined in @vue/runtime-core. So the Comment who referenced by comment is actually referenced lib.dom.d.ts's Comment. But the output of this example in transpileDeclaration APi will same as the following example.

Another example

NOTE: Enabled isolatedDeclaration and noCheck

Playground Link

// input
import { Comment } from 'does-not-exist'
export const comment: Comment 

// .d.ts output
import { Comment } from 'does-not-exist';
export declare const comment: Comment;

This example is also correct because the tsc checker can't analyze what Comment is, so the Comment was kept in the output as it has been referenced by comment.

But If I want to use lib.dom.d.ts's Comment rather than imported it will be incorrect.

Expect Behaviour

In IsolatedDeclarations, Ideally, we shouldn't analyze imports, but if we stop analyzing, this will generate incorrect output.

So I suggest tsc should throw an error about the above cases when IsolatedDeclarations is enabled. This is also beneficial for third-party IsolatedDeclarations implementations.

BTW this issue I found when I fix the output mismatch between oxc-isolated-declarations and tsc

Boshen commented 3 weeks ago

This came from trying to align oxc's ID implementation in vue.

To reproduce:

  1. Run https://github.com/vuejs/core/blob/5e8898572fa63438b57a4b2b1de01a85dc49655e/package.json#L9
  2. The file https://github.com/vuejs/core/blob/main/packages/runtime-core/src/hydration.ts
import {
  Comment,
} from './vnode'

export enum DOMNodeTypes {
  ELEMENT = 1,
  TEXT = 3,
  COMMENT = 8,
}

export const isComment = (node: Node): node is Comment =>
  node.nodeType === DOMNodeTypes.COMMENT

Is IDed to

import { type VNode } from './vnode';
export declare enum DOMNodeTypes {
    ELEMENT = 1,
    TEXT = 3,
    COMMENT = 8
}
export declare const isComment: (node: Node) => node is Comment;

Very confusingly, the actual usage for the imported Comment is in https://github.com/vuejs/core/blob/a177092754642af2f98c33a4feffe8f198c3c950/packages/runtime-core/src/hydration.ts#L198. Comment in node is Comment is resolved to the one in lib.dom.d.ts

We are unable to implementation this behavior in oxc due to the lack of type checker.


Edit: I ended up patching vue: https://github.com/vuejs/core/pull/12009

dragomirtitian commented 2 weeks ago

The root issue is that in isolated declaration mode we can't know if an import is a type or a value. The good news is that it should not matter for declaration emit, the only potential side effect is that we might leave an unused import in the declaration file. Let's consider the possibilities:

import { Comment } from '@vue/runtime-core'
export const comment: Comment 
  1. Import is a type&value - In this case Comment always refers to the import so it should be kept
  2. Import is a type - In this case the type position reference to Comment refers to the import so it should be kept in the file.
  3. Import is a value - In this case the type position reference to Comment refers to the global. However if we preserve the import, we still have a valid declaration (one with an unused import, but that is not an error)

The conclusion is that if we have an import, that is referenced from a type or value position it is always valid to keep it even if we don't know for sure if typescript actually resolves to the import or to some unseen global.

Ideally we will align the TS declaration emit to be the same as what a tool without type information will output (and keep the import, even if we know it to be unused), but even if they are different the no type checking tool can produce a valid declaration.