Skip to content

[d3-axis and d3-format] activate strictNullChecks and strictFunctionTypes#24305

Closed
denisname wants to merge 10 commits intoDefinitelyTyped:masterfrom
denisname:d3-format-strictNullChecks
Closed

[d3-axis and d3-format] activate strictNullChecks and strictFunctionTypes#24305
denisname wants to merge 10 commits intoDefinitelyTyped:masterfrom
denisname:d3-format-strictNullChecks

Conversation

@denisname
Copy link
Copy Markdown
Contributor

In this PR, I activated strictFunctionTypes in d3-axis. To get it work I also needed to update d3-format.

In d3-format:

  • Activate strictNullChecks in d3-format, just needed to change FormatSpecifier#precision.
  • Use type { valueOf(): number; } in format and formatPrefix.
  • Function format can take a string, see format-type-c-test.js.
  • Add @throws in JsDoc.

In d3-axis:

  • Remove "unified-signatures": false from tslint.json.
  • Use $ExpectError for failing tests.
  • Activate strictFunctionTypes in d3-axis: change Selection<SVGSVGElement | SVGGElement, any, any, any> by Selection<SVGSVGElement, any, any, any> | Selection<SVGGElement, any, any, any>.

I add a test for strictFunctionTypes. Not sure if it is the best way to do it. Also CI build fail for them because some $ExpectError lines are not detected as failing. I'm wondering if dtslint test the code with TS 2.7 or not.

Finally, I don't think any new features from TS 2.3 may improve those definitions. So I kept the minimum version to 2.0.

Related: #23611.

Please fill in this template.

  • Use a meaningful title for the pull request. Include the name of the package modified.
  • Test the change in your own code. (Compile and run.)
  • Add or edit tests to reflect the change. (Run with npm test.)
  • Follow the advice from the readme.
  • Avoid common mistakes.
  • Run npm run lint package-name (or tsc if no tslint.json is present).

Select one of these and delete the others:

If changing an existing definition:

  • Provide a URL to documentation or source code which provides context for the suggested changes: d3-format and d3-axis
  • Increase the version number in the header if appropriate.
  • If you are making substantial changes, consider adding a tslint.json containing { "extends": "dtslint/dt.json" }.

@denisname denisname requested a review from borisyankov as a code owner March 15, 2018 10:26
@typescript-bot
Copy link
Copy Markdown
Contributor

typescript-bot commented Mar 15, 2018

@denisname Thank you for submitting this PR!

🔔 @tomwanzek @gustavderdrache @borisyankov - please review this PR in the next few days. Be sure to explicitly select Approve or Request Changes in the GitHub UI so I know what's going on.

If no reviewer appears after a week, a DefinitelyTyped maintainer will review the PR instead.

@typescript-bot
Copy link
Copy Markdown
Contributor

@denisname The Travis CI build failed! Please review the logs for more information. Once you've pushed the fixes, the build will automatically re-run. Thanks!

@tomwanzek
Copy link
Copy Markdown
Contributor

@denisname Thank you for submitting this PR. Given the extent of the contemplated changes, I will take some time to review them. In particular, since at first glance, you are proposing diverting from some of the conventions we have used in writing previous definitions. I will explain in my comments.

I will take the time to digest and respond with motivations for some of the conventions we followed in the past. Should we reconsider some of them, I would prefer to have full alignment first and then update other definitions as appropriate for consistency.

So that being said, it might be worthwhile to hold off on your proposed efforts with d3-array, until we have such alignment. This will save cycles for you and reviewers, should there be items where we prefer to hold on to current conventions. With 30+ definitions in the core D3 universe, conventions matter for a consistent user experience.

Thanks in advance for your understanding.

@denisname
Copy link
Copy Markdown
Contributor Author

Ok. Note that I cut my changes into several small commits to make revision simpler.

@aozgaa aozgaa closed this Mar 17, 2018
@aozgaa aozgaa reopened this Mar 17, 2018
Copy link
Copy Markdown
Contributor

@tomwanzek tomwanzek left a comment

Choose a reason for hiding this comment

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

Great work, a few asks for change and some (at times iterated) comments.

I will separately get back to the default generics and TS >=2.3 question in a follow-up comment.

* An optional array of ten strings to replace the numerals 0-9.
*/
numerals?: string[];
numerals?: [string, string, string, string, string, string, string, string, string, string];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm on the fence when it comes to this change. While it is strictly correct, It might be a little cumbersome, i.e. lengthy, when handling. Assuming the locale definitions are mostly generated "manually" in literal form, it will will add some semantic type safety.

However, if in the installed base we have users assigning string[]-type variables/constants to FormatLocaleDefinition.numerals, their existing code will break. This is true even if the numerals array was simply defined as const s = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; localeDef.numerals = s;, as s would be inferred as string[].

When I initially wrote these definitions, I abstained from [string, string, ..., string] because it seemed too clunky.

*/
format(specifier: string): (n: number) => string;
format(specifier: string): (n: { valueOf(): number; } | string) => string;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I prefer to keep the existing definition as tight as it originally was, i.e. with a return type of (n: number) => string, we have historically taken the stance that TS definitions should be as semantically type-safe as possible, while being as flexible as necessary for real-world use cases. This is particularly true in the case of implicit type coercion sometimes done within D3 functions.

In the present case the official API documentation refers to a "number" being the input to the returned formatter-function. The test you added illustrates that you used a very artificial example, d3Format.format("$c")("☃"); which does not have real-world applicability, to test the relaxed interface. As a matter of fact, something like d3Format.format(".0%")("☃"); would return 'NaN%'.

We have kept these formatter functions tight to a number-argument without anyone having raised an issue in the past as to lack of flexibility in real life applications. If a consumer of these particular definitions wants to coerce an input to number, they should do so explicitly, knowing that it makes sense in their particular use case, i.e. d3Format.format(".0%")(+x); where x is coercible.

More generally, as I pointed out in my issue comment to your question about numeric corecibles, the omission of number makes the TS definition extremely hard to read for human users even when numeric coercibles are permissible inputs. See item (3) in the comment.

The definitions must serve two masters: The human being that is the developer and the TS compiler. Usability is important.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Update: Upon further review of the integration with d3-axis, let us go with the following return type for all format(...) and formatPrefix(...) functions:

(n: number | { valueOf(): number }) => string

We will need the support for coercible numeric values, as we support it on axis-/scale-related features. But please keep the number in the union for human readability to avoid unnecessary cognitive load for the average human user.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The matter of specifier type "c" is interesting in that the official API doc does not reflect the implemented code since this change to the d3-format code. The official API doc still refers to the old behaviour:

c - converts the integer to the corresponding unicode character before printing.

and the reference to the argument of the returned formatter function being a "number". So good on you for finding a rather undocumented behaviour in the source.

Seems to me on yet further consideration, that (a) the API doc should be updated to reflect the current behaviour (PR to d3-format) AND (b) we need a more complex explanation of the permissible inputs to the formatter functions as well, i.e. when an actual text string is permissible in combination with format-specifiers of type "c".

So, I stand corrected on the extension to also include string explicitly. No-one seems to have used it so far in actual applications with these TS definitions, otherwise we would have likely heard about it.

formatPrefix(specifier: string, value: number): (n: number) => string;
formatPrefix(specifier: string, value: number): (n: { valueOf(): number; }) => string;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same comment as for format(...), please keep the original return type signature.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Update: Upon further review of the integration with d3-axis, let us go with the following return type for all format(...) and formatPrefix(...) functions:

(n: number | { valueOf(): number }) => string

* @throws Error on invalid format specifier.
*/
export function format(specifier: string): (n: number) => string;
export function format(specifier: string): (n: { valueOf(): number; } | string) => string;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please revert to original signature. See comments above regarding format() and formatPrefix().

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Update: Upon further review of the integration with d3-axis, let us go with the following return type for all format(...) and formatPrefix(...) functions:

(n: number | { valueOf(): number }) => string

* @throws Error on invalid format specifier.
*/
export function formatPrefix(specifier: string, value: number): (n: number) => string;
export function formatPrefix(specifier: string, value: number): (n: { valueOf(): number; }) => string;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please revert to original signature. See comments above regarding format() and formatPrefix().

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Update: Upon further review of the integration with d3-axis, let us go with the following return type for all format(...) and formatPrefix(...) functions:

(n: number | { valueOf(): number }) => string

leftAxis = leftAxis.scale(scalePow());
const powerScale: ScalePower<number, number> = leftAxis.scale<ScalePower<number, number>>();
// powerScale = leftAxis.scale(); // fails, without casting as AxisScale is purposely generic
const powerScale: ScalePower<number, number> = leftAxis.scale() as ScalePower<number, number>;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Use declarative castingleftAxis.scale<ScalePower<number, number>>()


// --------------------------------------------------------------------------
// Strict Function Types
// --------------------------------------------------------------------------
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Laudable idea to think of explicitly testing strictFunctionTypes. However, it will not work, as you found, since dts-lint is running from the minimally permitted TS version up to the latest version. So for anything below TS 2.6 the tests will not fail as expected, as strictFunctionTypes were not enforced.

So I would simply remove this section.

containerElement = svg;
containerElement = g;
// containerElement = canvas; // fails, incompatible type
// $ExpectError
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for starting to use this dts-lint feature. We should start using this across the board on all the "fail" tests in D3. We had to historically comment them out (and manually check during definition updates). So let's roll with $ExpectError

gTransition.call(topAxis);

const svgSelection: Selection<SVGSVGElement, any, any, any> = select<SVGSVGElement, any>('g');
const svgSelection: Selection<SVGSVGElement, any, any, any> = select<SVGSVGElement, any>('svg');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good catch 😄 Now that's attention to detail!

* Interface defining an axis generator. The generic <Domain> is the type of the axis domain
* Interface defining an axis generator. The generic <Domain> is the type of the axis domain.
*/
export interface Axis<Domain> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I will review the constraints on Domain in the definition for Axis and AxisScale to define a suitable default for the generic parameters (TS 2.3 feature that we should use here as well). We might be able to do better than any for the most general permissible case.

We could have export interface Axis<Domain extends DomainConstraint = DomainDefault> {...} and export interface AxisScale<Domain extends DomainConstraint = DomainDefault> {...}. Then in day-to-day use one could just do let x: Axis; for brevity, if the defaults are good enough.

@tomwanzek
Copy link
Copy Markdown
Contributor

As for a minimal constraint on the generic Domain, we could define:

export type AxisDomain = number | string | Date | { valueOf(): number};

and then impose:

export interface AxisScale<Domain extends AxisDomain> {...}
...
export interface Axis<Domain extends AxisDomain> { ... }
...
export function axisTop<Domain extends AxisDomain>(scale: AxisScale<Domain>): Axis<Domain>;
export function axisRight<Domain extends AxisDomain>(scale: AxisScale<Domain>): Axis<Domain>;
export function axisBottom<Domain extends AxisDomain>(scale: AxisScale<Domain>): Axis<Domain>;
export function axisLeft<Domain extends AxisDomain>(scale: AxisScale<Domain>): Axis<Domain>;

Defaulting the generic for Axis and AxisScale to AxisDomain turns out to be impractical due to the impact of strictFunctionChecks.

Change back numerals type
Change format and formatPrefix
@tomwanzek
Copy link
Copy Markdown
Contributor

@denisname Penny for your thoughts...:smile:

@denisname
Copy link
Copy Markdown
Contributor Author

denisname commented Mar 24, 2018

  • numerals?: [string, string, ..., string]
    Fair point about assigning a string[]-inferred-type.

  • format[Prefix]
    Let's go for (n: number | { valueOf(): number }) => string.

  • strict function types test
    Removed!

  • scale generic:
    While making the changes, I realize that the tests line 80 and 87 are no failing as expected. The errors are caused by reassigning a const and not because there is a generic missing. The expected error stop happening with TS 2.4 which "improved inference for generics". I'm not sure what to do with the two tests. Best is probably to remove them!?

    I take this opportunity to say also that I disagree about scale being generic. See getMeAT for reasons. Also, if you don't like as keyword, <ScaleOrdinal<string, number>>bottomAxis.scale() is same number of characters than bottomAxis.scale<ScaleOrdinal<string, number>>(). One more with a space. But, I recognise the importance of keeping the different d3 definitions consistent and to avoid breaking backward compatibility. And as TS 2.4 makes the generic "optional", I'm going to set the generic of scale back… 😊

Edit: CI build failed as expected, see "scale generic".

@denisname
Copy link
Copy Markdown
Contributor Author

The AxisDomain constraint seems good. 👍

I also tried to solve the problem of ticks et ticksFormat typing and to remove: tickArguments(args: any[]): this;. Because of that overloading, any array is accepted by the compiler for tickArguments...

For this, I separated AxisScale in two (see full code)

export interface AxisScale<Domain> {
    (x: Domain): number | undefined;
    domain(): Domain[];
    range(): number[];
    copy(): this;
    bandwidth?(): number;
}

export interface TickedAxisScale<Domain, Tick> extends AxisScale<Domain> {
    ticks(count?: Tick): Domain[];
    tickFormat(count?: Tick, specifier?: string): ((d: Domain) => string);
}

I then removed ticks(.) and ticksFormat(.) from Axis<Domain>, and added:

export interface TickedAxis<Domain, Tick> extends Axis<Domain> {
    // ...
    ticks(count: Tick, specifier?: string): this;
    tickArguments(): [Tick] | [Tick, string];
    tickArguments(args: [Tick] | [Tick, string]): this;
}

export function axisTop<Domain, Tick>(scale: TickedAxisScale<Domain, Tick>): TickedAxis<Domain, Tick>;
export function axisRight<Domain, Tick>(scale: TickedAxisScale<Domain, Tick>): TickedAxis<Domain, Tick>;
export function axisBottom<Domain, Tick>(scale: TickedAxisScale<Domain, Tick>): TickedAxis<Domain, Tick>;
export function axisLeft<Domain, Tick>(scale: TickedAxisScale<Domain, Tick>): TickedAxis<Domain, Tick>;

Now type inference detect which types are allowed for ticks(.) and ticksFormat(.), see gist for all tests. For example:

d3Axis.axisTop(scaleLinear()).ticks(1, ',f'); // Ok
d3Axis.axisTop(scaleLinear()).ticks(timeMinute.every(5)!, ',f'); // fails

Provided that, in d3-scale, the two ticks(.) are merged: ticks(count: number | TimeInterval): Date[];.

In the gist I also added TickedCustomAxisScale. But, are there really any people who create scales that have a singnature for ticks and ticksFormat other than number or TimeInterval!? And if that's the case, is it not better for them to define their own adapted interface, rather than using TickedCustomAxisScale?

Hope my explanations are clear. Waiting for your comments. :)

@tomwanzek
Copy link
Copy Markdown
Contributor

@denisname Thanks, I will respond at my earliest. Unfortunately, I am little otherwise-committed right now 😄

@mhegazy
Copy link
Copy Markdown
Contributor

mhegazy commented Apr 9, 2018

Thanks for your contribution. This PR has failing CI tests and can not be merged in at the time being. For housekeeping purposes we are closing stale PRs. If you'd still like to merge this code in, please open a new PR that has been merged and rebased with the master branch.

@mhegazy mhegazy closed this Apr 9, 2018
@tomwanzek
Copy link
Copy Markdown
Contributor

@denisname My apologies for not having been faster than, it's been a little hectic. I would love to resurrect your work and get it going.

There are a few options (bar reopening this PR and re-basing it):
(1) You...
(2) I...
create a new PR with the latest state of this one and cross-reference.
The first option would clearly preserve your line contributions. Also, I would suggest you add yourself
to the maintainer listing in the header, as you are clearly committed to moving this and other D3 definitions forward and make quality contributions.

Then we take it from there...

As for the the specific comment on scale<...>() vs scale() as ... I take your point that it is recommended practice. Given implications for backwards-compatibility, I would suggest we go with:

scale<A extends AxisScale<Domain> = AxisScale<Domain>>(): A;

for now. It will allow scale() to work in conjunction with as without the need for casting the method.
Gentle off-ramp for a future change to using your original proposal.

These kind of changes have been a bit of a thorn in our side, because we cannot semantically version the definitions in their own right. I will create an RFC item to discuss something that has been on my mind for a while now. I will @-mention you alongside @gustavderdrache and @Ledragon as key stakeholders.

@tomwanzek
Copy link
Copy Markdown
Contributor

PS: Ignore the comment suggesting to add the default to the generic in scale<...>(). Of course, that is not needed, as the constraint will form the default in this case.

As for the lines that create problems for // $ExpectError, for one, if a const should be a let, let's fix that. That was the issue with let creating a linter error, if the re-assignment line is commented out.

If we are still running into issues where the error assertion does not work in some TS version, but works in others, maybe we should just remove that particular definitions test.

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.

5 participants