microsoft / TypeScript

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

JSDoc @augments doesn’t allow function calls to augment existing types #26675

Open dotnetCarpenter opened 6 years ago

dotnetCarpenter commented 6 years ago

I wrote a simple extension of jest.Matchers but I can not get the typescript type checker to recognize my extension.

TypeScript Version: 3.0.1 (and 3.1.0-dev.20180825) The description below is for 3.0.1 - for 3.1.0-dev.20180825 all jest functions show errors, so I don't even think that the parser gets to my type at all.

[ts] Cannot find name 'describe'.
[ts] Cannot find name 'test'.
[ts] Cannot find name 'expect

Used via vscode version: 1.26.1

Search Terms: is:issue is:open jsdoc extends is:issue is:open jsdoc @augments is:issue is:open jsdoc "@augments"

I first asked this as a question on StackOverflow but after waiting a few days, I now suspect it is a bug in typescript's JSDoc implementation.

I also read the FAQ and found the link to jest but didn't find any information there either.

I'm using plain JavaScript and the code runs perfectly.

Code

// @ts-check

const getFunctorValue = F => {
  let x
  F.fmap(v => x = v)
  return x
}

expect.extend({
  /**
  * @extends jest.Matchers
  * @param {*} actual The functor you want to test.
  * @param {*} expected The functor you expect.
  */
  functorToBe(actual, expected) {
    const actualValue = getFunctorValue(actual)
    const expectedValue = getFunctorValue(expected)
    const pass = Object.is(actualValue, expectedValue)
    return {
      pass,
      message () {
        return `expected ${actualValue} of ${actual} to ${pass ? '' : 'not'} be ${expectedValue} of ${expected}`
      }
    }
  }
})

/**
* @param {*} v Any value
*/
function just (v) {
  return {
    fmap: f => just(f(v))
  }
}

describe('Functor Law', () => {
  test('equational reasoning (identity)', () => {
    expect(just(1)).functorToBe(just(1))
  })
})

Expected behavior: expect().functorToBe should be recognized as a function.

Actual behavior: But in the line with expect(just(1)).functorToBe(just(1)), I get a red underline under functorToBe and the following error message:

[ts] Property 'functorToBe' does not exist on type 'Matchers<{ [x: string]: any; fmap: (f: any) => any; }>'. any

I got jest.Matchers from writing expect() in vscode and looked at the description.

image of vscode intellisense displaying jest.Matchers as return type for expect()

Playground Link:

Related Issues: No.

dotnetCarpenter commented 6 years ago

Link to JSDoc @augments / @extends: http://usejsdoc.org/tags-augments.html

dotnetCarpenter commented 6 years ago

After looking at the parser, it dawned on me that maybe the jest interfaces are wrongly weirdly typed.

expect.extend() takes a jest.ExpectExtendMap, which would mean that typescript should map my dynamic method to jest.ExpectExtendMap to jest.Matchers<R>, which of course is not possible. But when I use @extends, I am telling typescript that I am extending jest.Matchers but what if I'm not?

What if I'm passing a type to the generic type jest.Matchers<R>?

jest.Matchers<functorMatcher>:

/**
 * @class
 */
const functorMatcher = {
 /**
  * @method
  * @param {*} actual The functor you want to test.
  * @param {*} expected The functor you expect.
  */
  functorToBe(actual, expected) {
    const actualValue = getFunctorValue(actual)
    const expectedValue = getFunctorValue(expected)
    const pass = Object.is(actualValue, expectedValue)
    return {
      pass,
      message () {
        return `expected ${actualValue} of ${actual} to ${pass ? '' : 'not'} be ${expectedValue} of ${expected}`
      }
    }
  }
}

expect.extend(functorMatcher)

But that doesn't work because what happens is that expect(just('foo')) returns a type jest.Matchers<just> and not a jest.Matchers<functorMatcher>

Or maybe I'm guessing too much and should just wait for someone, who really knows what's going on, to enlighten me.

sandersn commented 6 years ago

Currently @augments doesn't work for constructor functions, but that's not what going on here, I think. I think you want to augment the type of extends. Typically Typescript has used merging for this in the past. In other words:

interface Expect {
   // normal stuff in expect
}
declare const expect: Expect

// Filename: mine.js
interface Expect {
  functorToBe(...): { pass, message }
}

I'm not sure of the best way to express this in jsdoc. Maybe @augments is it, maybe it's not. Thoughts?

sandersn commented 6 years ago

After a bit more thought, I think the core problem is that the statement that changes the type of expect is a side-effecting function call, not a declaration. Typescript doesn’t have the ability to mutate types like that.

I think your best bet in the near term is to use a separate .d.ts file to express the type mutation as a type merge as I outlined above, although that may require some work on jest’s types if they are not amenable to merging already.

This not a satisfying solution for projects that want to be pure Javascript, however. Let’s keep this issue open to track the idea of a mutation-as-merge jsdoc tag.

dotnetCarpenter commented 6 years ago

Using vscode I can find a index.d.ts file (not sure where it originates from) that has a interface to Expect.

/**
 * The `expect` function is used every time you want to test a value.
 * You will rarely call `expect` by itself.
 */
interface Expect {
    /**
     * The `expect` function is used every time you want to test a value.
     * You will rarely call `expect` by itself.
     *
     * @param actual The value to apply matchers against.
     */
    <T = any>(actual: T): Matchers<T>;
    /**
     * You can use `expect.extend` to add your own matchers to Jest.
     */
    extend(obj: ExpectExtendMap): void;
    // etc.
}

I would then add my own index.d.ts file with the following?

interface Expect {
  functorToBe(...): { pass, message }
}
// or
interface Functor {
  (value?: any): {
    fmap: (f: function): Functor
  }
}
interface Matchers<R> {
  functorToBe(actual: Functor, expected: Functor): { pass: boolean, message: string }
}

Or I would define it in my test file?

Also not sure how to describe it without resorting to the type function which does not seem to exist.

sandersn commented 6 years ago

Take a look at Module Augmentation on https://www.typescriptlang.org/docs/handbook/declaration-merging.html. Your second idea of merging with Matchers is basically right, I think, since that's what is returned by expect. Your index.d.ts would have to reference jest's index.d.ts via the name your import it as -- probably just 'jest'.

dotnetCarpenter commented 6 years ago

Hmm.. this doesn't seem quite right. But I'll take it to SO and stop polluting this issue.

import { Matchers } from 'jest'

interface Functor<T> {
  (value?: any): {
    fmap: (f: value) => Functor<T>
  }
}

interface Matchers<R> {
  functorToBe(actual: Functor<T>, expected:  Functor<T>): R
}

https://stackoverflow.com/questions/52082800/how-to-describe-the-interface-of-a-simple-just-functor-in-typescript