Skip to content

fix: make types compile under TypeScript 7.0 - #1458

Open
FelipeLahti wants to merge 2 commits into
sendgrid:mainfrom
FelipeLahti:fix/typescript-7-export-conflict
Open

FelipeLahti wants to merge 2 commits into
sendgrid:mainfrom
FelipeLahti:fix/typescript-7-export-conflict

Conversation

@FelipeLahti

@FelipeLahti FelipeLahti commented Apr 27, 2026

Copy link
Copy Markdown

Fixes #1454

@sendgrid/client and @sendgrid/mail mix export = instance with export {Class} and silence the diagnostic with // @ts-ignore. TypeScript 7.0 (the Go-port tsgo beta) tightens the rule and surfaces it in every consumer:

error TS2616: 'Client' can only be imported by using
'import Client = require("@sendgrid/client")' or a default import.

The two exports were never reconcilable as written, because module.exports is a singleton instance with a class attached to it (index.js does module.exports = client; module.exports.Client = Client), and a declare const cannot carry named exports.

So model that shape directly: declare an ambient namespace that merges with the export = target. The namespace holds the singleton's methods plus export {Client}, which keeps both meanings of the name — the class as a value and as an instance type — importable by name, with no @ts-ignore.

Same shape applied to MailService in packages/mail/src/mail.d.ts, where the namespace also carries the MailDataRequired, ClientResponse and ResponseError re-exports.

tsconfig.json is also bumped to TS 7.0 minimums so test/typescript/*.ts keeps type-checking under tsgo: baseUrl removed, leading ./ added to path mappings, types: ["node"] declared explicitly. Without this tsgo bails on the config before it type-checks anything.

No runtime change.

Verification

Both compilers pass:

$ tsc --noEmit -p tsconfig.json    # tsc 5.9.3 → exit 0
$ tsgo --noEmit -p tsconfig.json   # @typescript/native-preview 7.0.0-dev → exit 0

I also checked the consumer-visible surface end to end — a separate project with @sendgrid/mail@8.1.6 and @sendgrid/client@8.1.6 installed from npm and only the .d.ts files swapped, so resolution goes through the published index.d.ts rather than this repo's paths mapping:

consumer pattern main this PR
import sg = require("@sendgrid/client"), new sg.Client()
import { Client }new Client()
import { Client }c: Client (type position)
import { MailDataRequired, ClientResponse, ResponseError }
import * as sg from "@sendgrid/mail"
import sg from "@sendgrid/mail" (esModuleInterop)

Nothing that compiles against main stops compiling, and sg.Client — which the runtime has always provided but the types denied — now works.

The same matrix passes under tsgo with module: nodenext (CommonJS consumer). Against main, tsgo rejects every row with TS2595, because mail.d.ts itself imports Client by name.

I also tried the one-line alternative suggested in review, export default client; in place of export = client. It fails the repo's own test/typescript fixtures under both compilers (27× TS2339: Property 'setApiKey' does not exist on type 'typeof import(...)'), because import sg = require(...) and import * as sg then resolve to the module namespace, whose methods live on a .default that does not exist at runtime. It also lets import sg from "@sendgrid/mail" compile without esModuleInterop, which crashes at runtime with Cannot read properties of undefined (reading 'setApiKey'). The only pattern it satisfies is a default import in a CommonJS project with esModuleInterop on.

Known limitation: ESM namespace and named imports

In a native ESM consumer ("type": "module", module: nodenext), import * as sg from "@sendgrid/mail"; sg.setApiKey() and import { setApiKey } from "@sendgrid/mail" now type-check but fail at runtime. Node only exposes module.exports.MailService as a named export; the instance methods live on the prototype and are invisible to its CommonJS export detection. main rejected these at compile time because the singleton carried no namespace at all. This is the same gap every export = package with a namespace merge has, and TypeScript offers no way to model it (a const cannot merge with a namespace). ESM consumers should use the default import, import sg from "@sendgrid/mail", which compiles and runs under both tsc and tsgo. import { MailService } and import { Client } are fine in ESM, since those are real module.exports properties.

Fixtures

Three cases the existing fixtures did not reach are now covered, each of which fails against main:

  • new Client.Client() off the module (test/typescript/client.ts) — TS2339 on main
  • Client in type position (test/typescript/mail.ts) — the only usage the old export {Client} protected
  • the types re-exported from the package root (test/typescript/mail.ts)

Checklist

  • I acknowledge that all my contributions will be made under the project's license
  • I have made a material change to the repo (functionality, testing, spelling, grammar)
  • I have read the Contribution Guidelines and my PR follows them
  • I have titled the PR appropriately
  • I have updated my branch with the main branch
  • I have added tests that prove my fix is effective or that my feature works
  • I have added the necessary documentation about the functionality in the appropriate .md file
  • I have added inline documentation to the code I modified

Both `@sendgrid/client` and `@sendgrid/mail` ship .d.ts files that
mix `export = instance` with `export {Class}` and silence the
diagnostic with `// @ts-ignore`. Pre-7.0, downstream consumers paid
no cost for this since named imports of the class still resolved.
TS 7.0 (the Go-port `tsgo` beta) tightens the rule and surfaces
TS2616 in every consumer that imports `Client` or `MailService`:

    error TS2616: 'Client' can only be imported by using
    'import Client = require("@sendgrid/client")' or a default import.

The fix mirrors the runtime: `module.exports = new Client();
module.exports.Client = Client;`. Modeling that as a `Client`
instance with a `Client: typeof Client` instance member lets TS
expose the class through `client.Client` AND through the named
import `import { Client } from "@sendgrid/client"` without any
escape hatches. Same pattern for MailService. Existing TS test
fixtures (incl. `new Client()` and `setClient(client: Client)`)
keep passing under both tsc 5.9 and tsgo 7.0 unchanged.

Also bring tsconfig.json up to TS 7.0 minimums so the test fixtures
type-check under tsgo: drop the removed `baseUrl`, add the now-
required leading `./` to path mappings, and declare `types: ["node"]`
explicitly (auto-load of all @types is gone in 7.0).

Verified with tsc@5.9.3 and @typescript/native-preview@7.0.0-dev.
export = client

export {Client};
export = client;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I wanted to create an MR for the exact same reason. From my testing, this is the only change needed. No other changes are required.

export default client;

Of course, I'm not completely sure if this won't break anything else

Attaching `Client` as an instance member made it a value-only export, so
`function f(c: Client)` stopped type-checking, and it could not carry the
type-only `MailDataRequired`, `ClientResponse` and `ResponseError`
re-exports at all.

Declaring an ambient namespace merged with the `export =` target models
`module.exports` exactly — the singleton's methods plus the class attached
in index.js — and keeps both meanings of `Client` and `MailService`
importable by name. No `@ts-ignore`, no runtime change.

Fixtures now cover `client.Client`, `Client` in type position, and the
types re-exported from the package root; each of those failed to compile
under the previous shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Incorrect type definitions

2 participants