Skip to content

JSDoc @private / @protected are dropped in declaration emit for properties declared by constructor assignment #64628

Description

Version: typescript@7.0.2 (tsgo) · Area: Declaration Emit / JavaScript + JSDoc

Summary

When a .js file declares a class property the classical JSDoc way — a JSDoc-annotated assignment inside
the constructor — Corsa emits the member without its visibility modifier, and for @private also
without its type:

// a.js
export class A {
  constructor() {
    /** @private @type {boolean} */
    this.p1 = false;
    /** @protected @type {string} */
    this.p3 = '';
  }
}
compiler emitted a.d.ts
4.7.4, 4.9.5, 5.9.3, 6.0.3 /** @private @type {boolean} */
private p1;
/** @protected @type {string} */
protected p3: string;
7.0.2 /** @private @type {boolean} */
p1;
/** @protected @type {string} */
p3: string;

The member therefore becomes public in the published types, and a @private one additionally becomes
untyped, which turns into error TS7008: Member 'p1' implicitly has an 'any' type for every consumer that
does not set skipLibCheck: true.

Note the sibling form is emitted correctly — a property declared in the class body keeps the modifier:

export class B {
  /** @private @type {boolean} */
  f1 = false;
}

→ private f1; (both Strada and Corsa). Only the constructor-assignment form regresses.

Reproduction

tsconfig.json

{
  "compilerOptions": {
    "target": "ESNext", "module": "NodeNext", "moduleResolution": "NodeNext",
    "allowJs": true, "checkJs": true, "strict": true,
    "declaration": true, "emitDeclarationOnly": true, "outDir": "out",
    "skipLibCheck": true, "lib": ["es2017", "dom"]
  },
  "include": ["a.js"]
}
npx tsgo -p tsconfig.json && cat out/a.d.ts

Minimum repro is the A class above. Declaring it in the class body (B) is unaffected.

Real-world hit: @lion/ui (ING's design system)

@lion/ui is a ~900-file JSDoc-typed .js library that ships dist-types/*.d.ts. Everything is authored in
this style, so the regression affects 64 members in 21 files (measured by building the same commit with
typescript@4.9.5 and with 7.0.2 and comparing the emitted members); 58 of them can be worked around in
source, the other six cannot (see Notes).

Concrete reference on the current default branch (commit 949d32402 of
https://github.com/ing-bank/lion — file packages/ui/components/calendar/src/LionCalendar.js, around lines
190-191):

    /** @private */
    this.__today = normalizeDateTime(new Date());
  • Strada (the same commit built with typescript@4.9.5, i.e. what the package published until the
    TypeScript 7 move) emits, in dist-types/components/calendar/src/LionCalendar.d.ts:
        /** @private */
        private __today;
    
  • Corsa 7.0.2 (that commit's actual output) emits:
        /** @private */
        __today;
    

Measured over the whole package (same sources, same consumer):

emit of @lion/ui consumer errors at skipLibCheck: false
typescript@4.9.5 output 25, of which 0 × TS7008
7.0.2 output 100, of which 74 × TS7008

The 74 break down as 42 unique members across 16 files, e.g. LionCalendar (__today,
__connectedCallbackDone, __eventsAdded, __bound*Delegation …), OverlayController (7 members),
LionCombobox (5), OverlaysManager (4). The @protected half is worse than noise: protected _ariaVersion
becomes public _ariaVersion, and several downstream TS2611/TS2416/TS2417 errors follow from
protected members turning public.

Expected behaviour

For the constructor-assignment form, Corsa should emit what Strada emits: the visibility modifier
(private / protected, and presumably @readonly/@public likewise) and, for @private, keep dropping the
type exactly as Strada does — i.e. private p1;.

Notes

  • declare p1; is not an option for us in .js (TS8009: The 'declare' modifier can only be used in TypeScript files), so there is no source-level way to ask for the Strada output. An interface or
    @typedef that redeclares the member is inert too - an interface cannot narrow a class member's
    visibility and it does not influence what is emitted for it. The only workaround is to add a
    class-body field declaration carrying the JSDoc, which is not semantics-preserving (it changes
    initialization order / property-existence, and for Lit reactive properties it shadows the accessor Lit
    installs on the prototype), so we cannot take it for all members. We took it for 58 of the 64 members;
    the remaining six are Lit reactive properties.
  • The native-port staging repo (microsoft/typescript-go, now closed and pointing here) documented that declaration emit for .js input "has substantially changed behavior" and asked for issues where the .d.ts output from a .js file is incorrect - this looks like one.mdsays declaration emit for.jsinput "has substantially changed behavior" and asks for issues where the.d.tsoutput from a.js` file is incorrect — this looks like one.
  • Related, but different: Issues exporting a single d.ts for our code base #4315 (modifier order for @private static methods), JSDoc @private on constructor without parameters is omitted in declaration files #58653 (@private on a
    parameterless constructor), and Fix line endings in program.ts #3541, which targets the same this.x collection in JS declaration emit - the code path this report exercises.

Activity

  1. belentani7 commented on Oct 5, 2026

    @belentani7

    This is a declaration emit bug that affects JSDoc visibility annotations on constructor-assigned properties.

    Impact:
    When you declare a property via constructor parameter assignment (TypeScript's parameter properties), the Kristoffer Langeland Knudsen (@Private) and Protected JSDoc tags are not preserved in the generated .d.ts files. This breaks encapsulation documentation for consumers.

    Example:
    \\ ypescript
    class Foo {
    /** Kristoffer Langeland Knudsen (@Private) */
    constructor(private bar: string) {} // Kristoffer Langeland Knudsen (@Private) lost in .d.ts
    }
    \\

    Expected: The .d.ts should include /** Kristoffer Langeland Knudsen (@Private) */ bar: string;

    Fix location: Likely in the declaration emit transformer where parameter properties are converted to property declarations. The JSDoc extraction needs to run before the parameter property transformation.

    Workaround: Declare properties explicitly in the class body instead of using parameter properties.

    Want me to investigate the exact code path in the compiler?

  2. tlouisse commented on Oct 5, 2026

    @tlouisse
    Author

    Yes, it would be great if we can keep using this jsdoc annotation without having to do workarounds

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

BugA bug in TypeScript

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions