Search code examples
javascriptfunctionvisual-studio-codethisjsdoc

jsDoc to define constructor function properties that are assigned implicitly (not set with `this`)


Using jsDoc in VS Code, How could we define the properties of a constructor function that its properties are not set directly through this.property = value syntax, but implicitly via Object.assign() code as in following sample:

/** 
 * @constructor
 * @property {string} title - the @ property here has no effect to define such properties to IDE 
 */
function BookFn(title, description, year, author, ...) { //too many properties... , lang, genre, pages, isbn, copies, ...
  
  //instead of writing multiple this.x = x code,
  //we set our properties indirectly via 'Object.assign()' as follow:
  Object.assign(this, {title, description, year, author}); //, lang, genre, pages, isbn, copies, ...
  
}

BookFn.prototype = {
  titleAuthor() { return this.title + ' by ' + this.author}, //VSCode IDE doesn't know such properties!
};

let book1 = new BookFn('book1', 'description1', 2020, 'author1');
let t = book1.title; //VSCode IDE doesn't know such property!

I have tried the @property or @typedef to define explicitly our properties but it doesn't have any effect.

Note: I'm looking some way to say the signature of my constructor function to IDE without explicitly defining its properties or duplicate code as we see in most samples.


Solution

  • EDIT

    Classes are better to achieve what you want. They're internally functions in JavaScript. Here's an example like you asked:

    // The following class must be always
    // instanced using the `new` keyword.
    
    /**
     * JSDoc for your class here.
     * 
     * @template { string } [ NAME = 'Anonymous' ]
     * @template { string } [ SURNAME = 'Doe' ]
     */
    class Person
    {
       /**
        * JSDoc for your constructor here.
        * 
        * @param { NAME } [ Name = NAME ]
        * @param { SURNAME } [ Surname = SURNAME ]
        */
       constructor ( Name, Surname )
       {
          Name = String( Name ?? this.Name );
          Surname = String ( Surname ?? this.Surname );
          
          Object.assign(this, {Name, Surname});
       }
       
       /**
        * JSDoc for the `Name` instance property.
        * 
        * @type { NAME }
        */
       Name = 'Anonymous';
       
       /**
        * JSDoc for the `Surname` instance property.
        * 
        * @type { SURNAME }
        */
       Surname = 'Doe';
       
       /**
        * JSDoc for the `FullName` instance method.
        * 
        * @returns { string }
        */
       get FullName ()
       {
          return this.Name + ' ' + this.Surname;
       }
    }
    
    // The IntelliSense for the following code will
    // work just fine in Visual Studio Code.
    
    const NewAnonymousPerson = new Person;
    const NewCustomPerson = new Person('Klauss', 'SanGui');
    
    let AnonymousPersonName = NewAnonymousPerson.Name; // IntelliSense: 'Anonymous'
    let CustomPersonName = NewCustomPerson.Name; // IntelliSense: 'Klauss'
    
    console.log('NewAnonymousPerson.Name =', AnonymousPersonName);
    console.log('NewAnonymousPerson.Surname =', NewAnonymousPerson.Surname);
    console.log('NewAnonymousPerson.FullName =', NewAnonymousPerson.FullName);
    
    console.log('NewCustomPerson.Name =', CustomPersonName);
    console.log('NewCustomPerson.Surname =', NewCustomPerson.Surname);
    console.log('NewCustomPerson.FullName =', NewCustomPerson.FullName);

    Here's an example using arrow function:

    /**
     * JSDoc for the maker function here.
     * 
     * @template { string } [ NAME = 'Jonh' ]
     * @template { string } [ SURNAME = 'Doe' ]
     * 
     * @param { NAME } [ Name = NAME ]
     * @param { SURNAME } [ Surname = SURNAME ]
     */
    const PersonMaker = ( Name, Surname ) =>
    ({
       /**
        * JSDoc for this property here.
        * 
        * @type { NAME }
        */
       Name: String ( Name ?? 'Jonh' ),
       
       /**
        * JSDoc for this property here.
        * 
        * @type { SURNAME }
        */
       Surname: String ( Surname ?? 'Doe' ),
    });
    
    // IntelliSense works fine for the following code:
    
    const SomePerson = PersonMaker('Klauss', 'SanGui');
    
    SomePerson.Name;
    SomePerson.Surname;
    

    Here's a full working example of a function with fixes to make sure the constructor always works (with abstract documentation in another file):

    MyComponent.mjs

    // @ts-check
    /// filename: 'MyComponent.d.ts'
    
    /**
     * The `Person` constructor JSDoc here.
     * 
     * @type { PersonConstructor }
     */
    // @ts-expect-error
    export const Person = function Person ( Name = 'Jonh', Surname = 'Doe' )
    {
       return Object.assign
       (
          (
             (
                this instanceof Person
             )
             ?  this
             :  Object.create(new.target?.prototype ?? Person.prototype)
          ),
          { Name, Surname }
       );
    };
    export default Person;
    
    // The IntelliSense for the following lines works fine.
    
    const ReturnPerson = Person('Klauss', 'SanGui');
    const InstancePerson = new Person('Klauss', 'SanGui');
    
    ReturnPerson.Name;
    ReturnPerson.Surname;
    
    InstancePerson.Name;
    InstancePerson.Surname;
    

    MyComponent.d.ts¹ (abstract documentation)

    /**
     * A class/constructor for a Person instance.
     */
    type PersonConstructor
    <
       NAME extends unknown extends string
       ?  NAME
       :  never
       =  'Jonh',
       
       SURNAME extends unknown extends string
       ?  SURNAME
       :  never
       =  'Doe',
    >
    =
    (
       ( ( ... Arguments: any [] ) => PersonObject < NAME, SURNAME > )
       &
       ( new ( ... Arguments: any [] ) => PersonObject < NAME, SURNAME > )
    );
    
    /**
     * A Person instance.
     */
    type PersonObject < NAME, SURNAME > =
    {
       /**
        * JSDoc for the `Name` property here.
        */
       Name: NAME,
       
       /**
        * JSDoc for the `Surname` property here.
        */
       Surname: SURNAME,
    };
    
    • ¹ NOTE that MyComponent.d.ts is not executed in any case except by IntelliSense. If this file is missing, then there'll be IntelliSense errors only at MyComponent.mjs, but it will work just fine.

    Here's a full working example of a function with fixes to make sure the constructor always works (within one single file):

    /**
     * @template { string } [ NAME = 'Anonymous' ]
     * @template { string } [ PREFIX = 'Private' ]
     * @template { unknown } [ THIS = typeof theConstructor.prototype ]
     * 
     * @param { NAME } [ name = NAME ]
     * @param { PREFIX } [ prefix = PREFIX ]
     * 
     * @this { THIS }
     */
    function theConstructor ( name, prefix )
    {
       name = /** @type { NAME } **/
       (
          String(name ?? theConstructor.prototype.name)
       );
       prefix = /** @type { PREFIX } **/
       (
          String(prefix ?? theConstructor.prototype.prefix)
       );
       
       return false || /** @type { typeof theConstructor.prototype } **/
       (
          Object.assign
          (
            Object(this),
            {
              name,
              prefix,
              
              // The fix that implements what will be missed
              // without the `new` keyword.
              prefixedName: () =>
              {
                return theConstructor
                  .prototype
                  .prefixedName
                  .apply(Object(this));
              },
            }
          )
       );
    }
    
    theConstructor.prototype =
    {
       // Can't forget this if you're replacing the prototype.
       /**
        * Constructor accessor JSDoc here.
        */
       constructor: theConstructor,
       
       /**
        * Property JSDoc here.
        * 
        * @type { NAME | 'Anonymous' }
        */
       name: 'Anonymous',
       
       /**
        * Property JSDoc here.
        * 
        * @type { PREFIX | 'Private' }
        */
       prefix: 'Private',
       
       /**
        * Method JSDoc here.
        * 
        * @template { string } [ NEWPREFIX = this['prefix'] ]
        * 
        * @param { PREFIX | NEWPREFIX } [ newPrefix = PREFIX ]
        */
       prefixedName ( newPrefix )
       {
          // IntelliSense works just fine here:
          return ( newPrefix ?? this.prefix ) + '.' + this.name;
       },
       
       /**
        * Just an extra feature.
        */
       get [ Symbol.toStringTag ] ()
       {
          return this.prefixedName();
       },
    };
    
    const theAnonymous = new theConstructor;
    const theInstance = new theConstructor('Component', 'Namespace');
    
    // All the following will work in IntelliSense:
    theInstance.constructor;
    theInstance.name;
    theInstance.prefix;
    theInstance.prefixedName();
    
    // Testing the output in the console for theAnonymous.
    console.log('theAnonymous =', theAnonymous.toString());
    console.log(JSON.stringify(theAnonymous));
    
    // Testing the output in the console for theInstance.
    console.log('theInstance =', theInstance.toString());
    console.log(JSON.stringify(theInstance));
    
    // But this may cause issues:
    const theReturn = theConstructor('Unknown', 'People');
    // In this snippet case, as theConstructor inherits window
    // as the `this` keyword, then it'll replace the current
    // window properties with the object assignment inside the
    // constructor function.
    
    // Testing the output in the console for theReturn.
    console.log('theReturn =', Object.prototype.toString.apply(theReturn));
    console.log('theReturn.name =', theReturn.name);
    console.log('theReturn.prefix =', theReturn.prefix);
    
    // Fails to inherit from theConstructor's prototype if not fixed.
    console.log('theReturn.prefixedName =', theReturn.prefixedName);
    console.log('theReturn.prefixedName() =', theReturn.prefixedName());
    
    console.log('theReturn is globalThis.window?', Object.is(theReturn, globalThis.window));
    .as-console-wrapper { min-height: 100%; }

    NOTE that the original answer below explains more details about how Object.assign() and JSDocs works.

    ORIGINAL ANSWER

    This is kind of tricky...

    If you want the IntelliSense to recognize the assignment through Object.assign() then you have to use the return value.

    Here's an example:

    class classA
    {
       static prop1 = 1;
    }
    
    class classB
    {
       static prop2 = 2;
    }
    
    const classC = Object.assign(classA, classB);
    
    // IntelliSense will recognize this:
    console.log(classC.prop1);
    // Outputs: 1
    
    // IntelliSense will recognize this:
    console.log(classC.prop2);
    // Outputs: 2
    
    // JavaScript works fine, but IntelliSense will not recognize this:
    console.log(classA.prop2);
    // ----------------^
    // IntelliSense will show an error here.
    // But JavaScript will output: 2
    

    Also, there's another problem...

    function theConstructor ()
    {
       // ...
    }
    

    The function above can be called as theConstructor() or new theConstructor().

    If used without the new keyword, then the IntelliSense/JSDoc will infer the function return type. But if the new keyword is used, then the result will always be InstanceType < typeof theConstructor >.

    So, if your function is like follows it should work for most cases:

    /**
     * @template [ Initializer = {} ]
     * @param { Initializer } [ initializer = Initializer ]
     */
    function theConstructor ( initializer )
    {
       const target =
       (
          /**
           * No need to cast here.
           */
          ( ( typeof new.target != 'undefined' ) ? new.target : theConstructor )
       );
       
       const instance =
       (
          /**
           * No need to cast here.
           */
          ( ( this instanceof target ) ? this : new target )
       );
       
       const prototype =
       (
          /**
           * Cast the prototype as an instance of this class.
           * 
           * @type { InstanceType < typeof theConstructor < Initializer > > }
           */
          ( target.prototype )
       );
       
       // Make sure that `initializer` is an object to avoid JS error.
       initializer = Object(initializer);
       
       return Object.assign(instance, prototype, initializer);
    }
    
    // Set your default properties...
    theConstructor.prototype.title = 'Untitled';
    
    // TESTING:
    const initializer = { nonDefault: 'value' };
    const result = theConstructor(initializer);
    const instance = new theConstructor(initializer);
    
    // IntelliSense will recognize the following:
    {
       result.title;
       result.nonDefault;
       
       instance.title;
    }
    
    // IntelliSense will NOT recognize the following:
    {
       instance.nonDefault;
       // ------^
       // IntelliSense will show an error here.
    }
    

    I hope this can help you make your documentation work better for VSCode.