Skip to content

ESM X.test which have zero export statement, should be sub type of ESM X which have at least one export statement #57735

Description

@loynoir

🔎 Search Terms

  • dynamic import

  • type union

  • no export

  • export {}

  • export default {}

🕗 Version & Regression Information

⏯ Playground Link

No response

💻 Code

reproduce.ts

async function optionalImport(id: string) {
  switch (id) {
    case 'a':
      return await import('./a.js')
    case 'a.test':
      return await import('./a.test.js')
  }

  throw new Error()
}

export { optionalImport }

a.ts

export const foo = 42

🙁 Actual behavior

  • When a.test.ts
import { foo } from './a.js'
describe('reproduce', () => { it('reproduce', () => { foo }) })

type is

function optionalImport(id: string): Promise<typeof import("/path/to/reproduce/a.test")>
  • When a.test.ts
import { foo } from './a.js'
describe('reproduce', () => { it('reproduce', () => { foo }) })
export {}

type is

function optionalImport(id: string): Promise<typeof import("/path/to/reproduce/a.test")>
  • When a.test.ts
import { foo } from './a.js'
describe('reproduce', () => { it('reproduce', () => { foo }) })
export default {}

type is

function optionalImport(id: string): Promise<typeof import("/path/to/reproduce/a") | typeof import("/path/to/reproduce/a.test")>

🙂 Expected behavior

When a.test.ts

import { foo } from './a.js'
describe('reproduce', () => { it('reproduce', () => { foo }) })

type is

function optionalImport(id: string): Promise<typeof import("/path/to/reproduce/a") | typeof import("/path/to/reproduce/a.test")>

Additional information about the issue

No response

Activity

  1. changed the title [-]dynamic import `foo.test.ts` without `export default {}` leads to wrong type union[/-] [+]merge dynamic import `foo.test.ts` without `export default {}` leads to wrong type union[/+] on Mar 12, 2024
  2. RyanCavanaugh commented on Mar 12, 2024

    @RyanCavanaugh
    Member

    You have two modules shapes here:

    • The one from a, which has a foo: number property
    • The one from a.test, which has either nothing, nothing, or a default: {} property

    The "nothing" variants supertype the { foo: number } property, so the union import(...) | import(...) gets reduced to the more-general of the two (nothing)

  3. loynoir commented on Mar 13, 2024

    @loynoir
    Author

    Ryan Cavanaugh (@RyanCavanaugh)

    But if a.test don't have export statement, I think it should be something like Record<string, never>.

    Because the only way to let ESM module have X property, is using export statement.

    If there is zero export statement, it means a zero key object Record<string, never>.

    Right?


    Change to a.test.mts

    • still get typeof import("/path/to/reproduce/a.test")

    • instead of something like {foo: 42} | Record<string, never>.

  4. RyanCavanaugh commented on Mar 13, 2024

    @RyanCavanaugh
    Member

    That would be a very bad type; given x: Record<string, never> you can write nonsense like Math.sin(x.foo) without error

  5. loynoir commented on Mar 13, 2024

    @loynoir
    Author

    https://github.com/typescript-eslint/typescript-eslint/blob/609a0003663e0409f1d277a3f7649d17b98b5cdb/packages/eslint-plugin/src/rules/ban-types.ts#L101

    Quote recommendation from @typescript-eslint/ban-types

    If you want a type meaning "empty object", you probably want Record<string, never> instead.

  6. fatcerberus commented on Mar 14, 2024

    @fatcerberus

    The TypeScript maintainers have a... let's say... contentious relationship with the ban-types rule, specifically the part about it banning {}

    Either way, Record<string, never> doesn't mean "empty object", regardless of what eslint recommends. It actually means "object where accessing any property throws an exception". Reading a property from an empty object doesn't give you a value of type never (i.e. an exception), it gives you undefined.

  7. loynoir commented on Mar 14, 2024

    @loynoir
    Author

    But, compared with {} which means any-alike, Record<string, never> seems to be yet the most correct type, to describe empty-object, maybe say always-zero-key-object, which describe an ESM module without any export statement.

  8. RyanCavanaugh commented on Mar 15, 2024

    @RyanCavanaugh
    Member

    If you want a type meaning "empty object", you probably want Record<string, never> instead.

    The typescript-eslint maintainers are uncharacteristically super wrong about this. { } is a valid type with a valid meaning and it's just wrong for them to ban it. Unfortunately we haven't had much luck changing their minds.

  9. JoshuaKGoldberg commented on Mar 17, 2024

    @JoshuaKGoldberg
    Contributor

    👋 typescript-eslint maintainer here. The ban-types rule's default options were formed many years ago when TypeScript had less fleshed out rules around {}, object, and related. If there's evidence that the rule's options are now and/or always were wrong about something, we'd happily take an issue to improve them. The dev lead for TypeScript saying the rule is super wrong is pretty compelling evidence. 😄

    Also looking at typescript-eslint/typescript-eslint#5018 (comment):

    There's just a lot of confusion likely to come down the road on this specific ban in general, since we're likely to change the definition of NonNullable to T & { } in 4.8, and are already in 4.8 going to change the default narrowing rules such that a truthy narrowing of a value of an unconstrained type parameter becomes T & { }. Having typescript-eslint, out of the box, ban you from writing a type that TypeScript itself is inferring under totally normal operation, is awkward.

    TypeScript did in fact ship the change to make type NonNullable<T> = T & {}; in #49119 - along with a host of other {} improvements. I'd wager that there's even more evidence we should revisit the ban.

    Three related issues on our side:

    For context: typescript-eslint is a separate maintenance team from TypeScript. Our opinions can sometimes fall out of sync with TypeScript best practices because we -like many independent open source projects- generally only take action in response to user prodding. We haven't been prodded about ban-types (that I know of) in quite a while. If there's anything ban-types or any other rule is doing that's out of sync with how TypeScript is meant to be, and TypeScript's handling of that rule's area has changed since the last time that rule was discussed, we'd happily take an issue prodding us to change. ❤️

  10. loynoir commented on Mar 17, 2024

    @loynoir
    Author

    As far as I see, maybe should split empty object type into another issue, if needed, as there are now two issue?

    Josh Ghoulberg 👻 (@JoshuaKGoldberg)


    The original issue is

    Actual

    ESM X.test which have zero export statement, is actually super type of ESM X which have at least one export statement

    Expected

    ESM X.test which have zero export statement, should be sub type of ESM X which have at least one export statement.

    Because it is

    • zero-key-object-at-read

    • not zero-key-object-at-write, but consider as nop

    > mod = await import('/tmp/empty-file.mjs')
    > mod.x=1
    1
    > mod.x
    undefined
  11. changed the title [-]merge dynamic import `foo.test.ts` without `export default {}` leads to wrong type union[/-] [+]ESM `X.test` which have zero export statement, should be sub type of ESM `X` which have at least one export statement[/+] on Mar 17, 2024
  12. RyanCavanaugh commented on Mar 17, 2024

    @RyanCavanaugh
    Member

    There's no type in TypeScript that means "a type that is guaranteed to be completely empty" because TypeScript doesn't have sealed/exact types

  13. typescript-bot commented on Mar 20, 2024

    @typescript-bot
    Contributor

    This issue has been marked as "Not a Defect" and has seen no recent activity. It has been automatically closed for house-keeping purposes.

  14. locked as resolved and limited conversation to collaborators on Oct 22, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Not a DefectThis behavior is one of several equally-correct options

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions