-
Notifications
You must be signed in to change notification settings - Fork 165
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Start using TypeScript #1450
Start using TypeScript #1450
Conversation
4908f7f
to
79edc62
Compare
I thought you'd just delete the types when porting the badges, but you decided to add Typescript to auspice instead :) Looks good! Mild concern only that in package.json typings do not correspond to the versions of js packages (e.g. types for react and styled-components are a major version ahead), which may make type checking and text editor assist less useful and even harmful. Otherwise I am super jazzed about auspice getting some types! |
79edc62
to
c1dbf7d
Compare
} else if (geoFilter.length===2 && tree && tree.nodes) { | ||
return tree.nodes.map((d) => determineLocationMatch(d, ...geoFilter) ? tipRadiusOnLegendMatch : tipRadius); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was surprised TS didn't allow ...geoFilter
given that the preceding if
statement ensures there are 2 elements. Perhaps three's some gotchas in the spread operator that I don't understand.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Compiled languages rarely check array lengths[1], because it often requires runtime information, while compilers only have information available at compile time. In this particular case, it might be possible to prove that array size is 2, but in many cases it's not.
I'd say, this code is questionable. If geoFilter
always has two items, then why it's not an object with named fields. They are even given names inside determineLocationMatch()
.
It looks like an abuse of container type. I guess it can also be empty (parameter is defaulted to []
)? Can it be anything else? 1 element? How about 3? Long story short, it's a bit of a sloppy, indecisive code, because it does not know what it wants.
Typescript does not make it easy to write code where things constantly transmute - an elephant becomes a banana and then in another file it becomes a space ship. Typescript requires some discipline. You need to decide things in advance and spell them. It puts constraints onto the Wild West of JS, and tries to make code less dynamic and easier to reason about.
Ideally I'd write something like:
// TODO: prehaps rename fields to have less of repeated "geo" prefix
export interface GeoFilters {
geoResolution: string;
geoValueToMatch: string;
}
function calcTipRadii(
// ... more params
geoFilters?: GeoFilters // can be `undefined`
// ... more params
) {
// ...
if (geoFilters) {
return tree.nodes.map((node) => determineLocationMatch(node, geoFilters))
}
// ...
}
// This function is very short and can probably be inlined, unless reused elsewhere
function determineLocationMatch(
node: Node,
{ geoResolution, geoValueToMatch }: GeoFilters // destructuring
) {
return geoValueToMatch === getTraitFromNode(node, geoResolution);
}
If you really want to abuse the container type (e.g. because it comes from a stable file format, and you don't want to write additional conversions), then in Typescript there's a tuple type, which can emulate fixed-size array. You could make geoTypes
of type [string, string]
, which is different from string[]
. But in this particular code geoTypes
defaults to []
, and []
cannot be of type [string, string]
. So it needs additional re-thinking.
--
[1] One exception is Rust, where you have separate Vec
type for dynamic arrays and array type [i32;N]
for fixed-size arrays. The N
in the array type is then a part of the type, and is checked on compile-time, while Vec
is only checked at runtime.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @ivan-aksamentov - since TS allows conditional statements to narrow the types I expected this to be ok; perhaps it doesn't like it because we may have overriden the length
prototype?
And fully agree with your general comments about the code -- this is one of the reasons I want to mostly write things in TS going forward (as well as the implicit documentation it'll provide when reading/writing code). However I didn't want to labour this PR with code changes, I just tried to find the most self-contained file to port to TS I could.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interestingly, changing the type from geoFilter:([string, string]|[])
to geoFilter?:[string, string]
(and therefore using undefined
for the default argument) does allow the spread operator to be used. I'm not going to make this change in this PR as there's other files which call this function using an empty array argument, but it was an interesting insight into how TS works.
export const calcTipRadii = (
- {tipSelectedIdx = false, selectedLegendItem = false, geoFilter = [], searchNodes = false, colorScale, tree}:
- {tipSelectedIdx:(number|false), selectedLegendItem:(number|string|false), geoFilter:([string, string]|[]), searchNodes:any, colorScale:any, tree:any}
+ {tipSelectedIdx = false, selectedLegendItem = false, geoFilter = undefined, searchNodes = false, colorScale, tree}:
+ {tipSelectedIdx:(number|false), selectedLegendItem:(number|string|false), geoFilter?:[string, string], searchNodes:any, colorScale:any, tree:any}
) => {
if (selectedLegendItem !== false && tree && tree.nodes) {
return tree.nodes.map((d:any) => determineLegendMatch(selectedLegendItem, d, colorScale) ? tipRadiusOnLegendMatch : tipRadius);
- } else if (geoFilter.length===2 && tree && tree.nodes) {
- return tree.nodes.map((d:any) => determineLocationMatch(d, geoFilter[0], geoFilter[1]) ? tipRadiusOnLegendMatch : tipRadius);
+ } else if (geoFilter?.length===2 && tree && tree.nodes) {
+ return tree.nodes.map((d:any) => determineLocationMatch(d, ...geoFilter) ? tipRadi
usOnLegendMatch : tipRadius);
} else if (searchNodes) {
return tree.nodes.map((d:any) => d.name.toLowerCase().includes(searchNodes) ? tipR
adiusOnLegendMatch : tipRadius);
} else if (tipSelectedIdx) {
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With the changes @victorlin has made, as of 18c809b (and tsc
version 5.0.4) I can't reproduce my original error. Which is great -- I am glad typescript can infer this. I'm not inclined to revert back the change (i.e. go back to the spread operator); as Ivan alludes to, we'll hopefully improve the underlying structure over time.
I just realized I have a bunch of typings written for Aupice in Nextclade. These are just the things that are used in Nextcade, and written long time ago, so many things may be missing or inaccurate and the tree type is for some reason in a separate place: |
src/util/getGenotype.js
Outdated
export const decodePositions = (positions, geneLength = 'Infinity') => { | ||
return positions | ||
.split(",") | ||
.map((x) => parseInt(x, 10)) | ||
.filter((x) => x > 0 && x <= Math.floor(geneLength)); | ||
}; | ||
|
||
/* Examples: | ||
* gt-nuc_142 → { gene: "nuc", aa: false, positions: [142] } | ||
* gt-nuc_142,144 → { gene: "nuc", aa: false, positions: [142,144] } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These weren't flagged as errors previously, however the previous commit swapped the rule "no-use-before-define" to "@typescript/no-use-before-define".
[…]
I don't know enough about specifications to know why this is considered an error but still works in practice, however it's not much effort to reorganise the code in these 2 files.
I find this sort of reorganization actively harmful to the ability to read the code in a file. Reading top to bottom, I want to read the higher level functions first, which call the lower level functions that I'll read next. The opposite is like putting the cart before the horse. decodePositions
is out of context and makes no sense until I see it called in decodeColorByGenotype
.
This is why in Auspice 6f22d9f disabled no-use-before-define
for functions, based on my change to nextstrain.org's config.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's a few things at play here.
Firstly, I didn't notice the existing eslint overrule for that line [1]:
auspice/src/util/getGenotype.js
Line 45 in b58f148
const positions = decodePositions(encodedPositions, geneLength); // eslint-disable-line no-use-before-define |
With this PR this overrule needs to change to // eslint-disable-line @typescript-eslint/no-use-before-define
, which I'll do as part of this PR. (This keeps the code as intended, which is appropriate for this PR.)
Secondly, I can't remember the specifics of why the following change was made (by me, but over a year ago):
- no-use-before-define: ["error", { "functions": false }]
+ no-use-before-define: "off" # note you must disable the base rule as it can report incorrect errors
+ "@typescript-eslint/no-use-before-define": ["error", { "functions": false }]
The intention is to continue to allow function (not fat-arrow) hoisting as per 6f22d9f (rule docs here). Linting behavior under no-use-before-define": ["error", { "functions": false }]
:
# Not valid JS, and has linting errors
const foo = () => bar();
foo();
const bar = () => {console.log("42");};
# Valid JS, and no linting errors
const foo = () => bar();
foo();
function bar() {console.log("42");}
# Valid JS, but linting fails because of the introduction
# of a temporal dead zone. Allowable by using
# ["error", { "functions": false, "variables": false}]
const foo = () => bar();
const bar = () => {console.log("42");};
foo();
The third example is why we get the errors in choose-layout.js
[2], however why they were flagged up now and not before I don't understand 🤷 . Note that while setting "variables": false
allows the existing code in choose-layout.js
it also allows the first toy example above to pass linting, despite it throwing a ReferenceError
when run.
[1] You wrote that override a ~year before we allowed function hoisting in 6f22d9f, but it's not a function declaration so it wouldn't be affected by "functions": false
anyways.
[2] And also in getGeontype.js
if we didn't have the eslint overrule for that line
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah yeah, I missed some subtly. Nice breakdown of it. Thanks!
With this PR this overrule needs to change to
// eslint-disable-line @typescript-eslint/no-use-before-define
, which I'll do as part of this PR. (This keeps the code as intended, which is appropriate for this PR.)
Sounds good.
however why they were flagged up now and not before I don't understand 🤷. Note that while setting
"variables": false
allows the existing code inchoose-layout.js
it also allows the first toy example above to pass linting, despite it throwing aReferenceError
when run.
Agreed we don't want to miss dangerous dead zones by setting variables: false
, but it'd be nice to ignore harmless dead zones.
I briefly tried to identify why the ESLint parser change and/or no-use-before-define rule change caused this, but couldn't find anything conclusive. My guess would be that the ESLint AST emitted by the parser (for the same code) is different, and why the rule triggers now but not before would be more apparent if we could compare the ASTs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Compared ASTs for this toy example:
class Foo extends React.Component {
renderX() {
return (<Bar />);
}
}
const Bar = ({ }) => (<>bar</>);
using https://astexplorer.net with @babel/eslint-parser
and @typescript-eslint/parser
. Sorting the JSON output and diffing it produces nothing obvious. It's very similar. So maybe Typescript's no-use-before-define
rule supports JSXIdentifier
nodes and the standard rule just… doesn't?
OTOH, signs still point to a parsing difference when I interrogate the example above with npx eslint --ext .js,.ts,.jsx,.tsx --parser @babel/eslint-parser
vs. … --parser @typescript-eslint/parser
and also try switching between the two rules.
parser / rule | no-use-before-define |
@typescript-eslint/no-use-before-define |
---|---|---|
@babel/eslint-parser |
ok | ok |
@typescript-eslint/parser |
ok | errors |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice work going down that rabbit hole!
As part of PR #1665, 9fc42f0 turned decodePositions
into a function declaration which is hoisted:
- export const decodePositions = (positions, geneLength = 'Infinity') => {
+ export function decodePositions(positions, geneLength = 'Infinity') {
This is left unchanged by this PR.
The changes to choose-layout.js
still remain in this PR -- af4c742 -- due to the change in interpretation between @babel/eslint-parser
and @typescript-eslint/parser
(as per Tom's message above).
I think this thread can be considered resolved.
Aside from the threaded discussions happening above, how do we feel about merging this PR? Specifically @ivan-aksamentov, @tsibley, @joverlee521 and @victorlin - but I welcome anyone else's thoughts as well! I either want to discard this PR or merge it (with appropriate fixes), not leave it hanging for another year! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Happy to see this merged. Good to get Typescript support going!
(Would be good to resolve the outstanding threads and minor fixes before merging.)
src/util/getGenotype.js
Outdated
export const decodePositions = (positions, geneLength = 'Infinity') => { | ||
return positions | ||
.split(",") | ||
.map((x) => parseInt(x, 10)) | ||
.filter((x) => x > 0 && x <= Math.floor(geneLength)); | ||
}; | ||
|
||
/* Examples: | ||
* gt-nuc_142 → { gene: "nuc", aa: false, positions: [142] } | ||
* gt-nuc_142,144 → { gene: "nuc", aa: false, positions: [142,144] } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah yeah, I missed some subtly. Nice breakdown of it. Thanks!
With this PR this overrule needs to change to
// eslint-disable-line @typescript-eslint/no-use-before-define
, which I'll do as part of this PR. (This keeps the code as intended, which is appropriate for this PR.)
Sounds good.
however why they were flagged up now and not before I don't understand 🤷. Note that while setting
"variables": false
allows the existing code inchoose-layout.js
it also allows the first toy example above to pass linting, despite it throwing aReferenceError
when run.
Agreed we don't want to miss dangerous dead zones by setting variables: false
, but it'd be nice to ignore harmless dead zones.
I briefly tried to identify why the ESLint parser change and/or no-use-before-define rule change caused this, but couldn't find anything conclusive. My guess would be that the ESLint AST emitted by the parser (for the same code) is different, and why the rule triggers now but not before would be more apparent if we could compare the ASTs.
src/util/getGenotype.js
Outdated
export const decodePositions = (positions, geneLength = 'Infinity') => { | ||
return positions | ||
.split(",") | ||
.map((x) => parseInt(x, 10)) | ||
.filter((x) => x > 0 && x <= Math.floor(geneLength)); | ||
}; | ||
|
||
/* Examples: | ||
* gt-nuc_142 → { gene: "nuc", aa: false, positions: [142] } | ||
* gt-nuc_142,144 → { gene: "nuc", aa: false, positions: [142,144] } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Compared ASTs for this toy example:
class Foo extends React.Component {
renderX() {
return (<Bar />);
}
}
const Bar = ({ }) => (<>bar</>);
using https://astexplorer.net with @babel/eslint-parser
and @typescript-eslint/parser
. Sorting the JSON output and diffing it produces nothing obvious. It's very similar. So maybe Typescript's no-use-before-define
rule supports JSXIdentifier
nodes and the standard rule just… doesn't?
OTOH, signs still point to a parsing difference when I interrogate the example above with npx eslint --ext .js,.ts,.jsx,.tsx --parser @babel/eslint-parser
vs. … --parser @typescript-eslint/parser
and also try switching between the two rules.
parser / rule | no-use-before-define |
@typescript-eslint/no-use-before-define |
---|---|---|
@babel/eslint-parser |
ok | ok |
@typescript-eslint/parser |
ok | errors |
Regarding There are also additional rules that use type information: Usually ts rules, including typescript-eslint rules, should work well for js. But if needed, eslint allows to configure lints for .js and .ts files separately, either using 2 separate configs, or by using "overrides" section. |
ad612cb
to
5ce2ed1
Compare
I've updated this PR with improvements to webpack/ESLint/TS configs and converted a couple more files over to TS. This PR should be re-reviewed. |
@victorlin IMO typescript-eslint is still not configured correctly, as I mentioned above. The |
@ivan-aksamentov I don't think extending Regardless, I tried: extended |
Start a TS config file, add npm scripts, and add CI. Typescript config is mostly via `tsc -init` and will be modified by later commits. Co-authored-by: James Hadfield <[email protected]>
Follow instructions for typescript-eslint¹, except the addition of plugin:@typescript-eslint/recommended - that will come in a future commit. Instead, use plugin:@typescript-eslint/eslint-recommended to have compatibility with the existing use of eslint:recommended². Adjust existing rules to use @typescript-eslint counterparts. ¹ https://typescript-eslint.io/getting-started ² https://typescript-eslint.io/linting/configs#eslint-recommended
These weren't flagged as errors previously, however the previous commit swapped the rule "no-use-before-define" to "@typescript/no-use-before-define". These seem to be false-positives (the code works just fine in practice) related to named exports. For instance, the following "works" but fails linting: ``` export const a = () => b; // error: 'b' was used before it was defined. export const b = 42; ``` This is an error even if we enable the "allowNamedExports" option - see https://eslint.org/docs/latest/rules/no-use-before-define#allownamedexports I don't know enough about specifications to know why this is considered an error but still works in practice, however it's not much effort to reorganise the code in this file.
For resolve.extensions and babel-loader. For resolve.extensions specifically, 1. '...' is used to append to the default list¹. 2. The order of ts before tsx is somewhat intentional. Even though it doesn't make a difference here (in our currently limited use of TypeScript), others have reported that the order can matter for unclear reasons². ¹ https://webpack.js.org/configuration/resolve/#resolve-extensions ² https://stackoverflow.com/a/49708475/4410590
This will frame decisions made in subsequent commits.
Although there are other JavaScript files outside of src/, the initial aim for TypeScript adoption in this project is scoped to the Auspice package, whose project files fall under src/.
This allows TS files to import from JS files. Don't set checkJs because JS files don't need to be type-checked. Don't set maxNodeModuleJsDepth because there is no need to include JS files from node_modules.
Default values suffice. Reasons: - lib: We don't need anything outside of the default set of type definitions. - experimentalDecorators: We aren't using experimental decorators. - noLib: We aren't providing type definitions for primitives.
node_modules contains declaration files from dependencies which cause errors when run through tsc. These errors are not relevant to compiling Auspice code. Setting skipLibCheck=true suppresses checking of *all* declaration files, which is a common workaround¹ to remove the errors described above. However, this includes custom declaration files in the codebase. Even though there aren't any now, they might come later during further TypeScript adoption. An unset `types` setting is the reason why node_modules/@types is included for compilation by default². Set it to an empty array since there are no declaration files that need to be explicitly included here yet. Also remove `typeRoots` since it is targeting the same thing as `types`. ¹ https://stackoverflow.com/a/57653497/4410590 ² https://www.typescriptlang.org/tsconfig#types
There's no good reason to check declaration files managed by TypeScript. This also saves a fraction of a second during compilation.
This passes now, meaning there isn't any unreachable code detected. Enable this check so any unreachable code must be intentional.
This passes now, meaning there aren't any unused labels detected. Enable this check so any unused labels must be intentional.
These all pass now. Enable them so any inconsistencies must be intentional.
There are no symlinks in this project, so this is not necessary.
There are no module imports with a ".json" extension in this project, so this is not necessary.
The default behavior seems fine - with the way include and allowJS are set, this shouldn't have an affect anyways.
There are no expectations for global UMD exports in this project, so this is not necessary.
There are no module references in this project that require a custom base directory or path, so these are not necessary. In the future, this *might* be applicable for the @extensions webpack alias if files emitted by tsc are used instead of babel.
'node' is required for node_modules to be considered during an import¹. ¹ https://www.typescriptlang.org/docs/handbook/module-resolution.html#how-nodejs-resolves-modules
More as a proof-of-principle, but even using a lot of `any`-types is an improvement over the previous code.
@types/node must be installed and "node" must be added to types so that "process" can be understood. "noPropertyAccessFromIndexSignature" must be disabled so that `process.env.EXTENSION_DATA` can be used without error.
This requires bumping react-i18next to v11.15.6, the earliest version to contain a bug fix for TypeScript ≥ 4.6¹. ¹ i18next/react-i18next@306e03a
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM 🎉 🙌
This is a band-aid fix to an issue with tsc on src/map.js using leaflet-gesture-handling's TS declaration file, which requires leaflet types that are not provided by the leaflet package. See this GitHub PR discussion¹ for a complete explanation and alternative solutions. ¹ #1450 (comment)
Description of proposed changes
Adds basic TypeScript support and conversion of a few files into TS/TSX.
See commit messages.