Skip to content
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

Add types to ListResources #874

Merged
merged 6 commits into from
Jun 5, 2024
Merged

Conversation

victorlin
Copy link
Member

@victorlin victorlin commented May 21, 2024

Description

Taking a first stab at applying TypeScript to a set of components. Suggestions and discussion welcome for how I've used TypeScript in this PR, as it's likely to shape the way we use TypeScript in this project going forwards.

Related issues

Discussions

  • function style (ref)
  • type vs. interface (ref)
  • type/non-null assertions (ref)

Checklist

  • Preview looks good, no functional changes
  • Checks pass

@victorlin victorlin self-assigned this May 21, 2024
@nextstrain-bot nextstrain-bot temporarily deployed to nextstrain-s-victorlin--pb9n66 May 21, 2024 23:45 Inactive
@nextstrain-bot nextstrain-bot temporarily deployed to nextstrain-s-victorlin--3tkubj May 21, 2024 23:53 Inactive
@nextstrain-bot nextstrain-bot temporarily deployed to nextstrain-s-victorlin--wzhki5 May 22, 2024 18:42 Inactive
@victorlin victorlin force-pushed the victorlin/list-resources-typing branch from 3e0350d to dc2284d Compare May 22, 2024 18:45
@victorlin victorlin temporarily deployed to nextstrain-s-victorlin--wzhki5 May 22, 2024 18:46 Inactive
Copy link
Contributor

@genehack genehack left a comment

Choose a reason for hiding this comment

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

++1 for getting started on this! Left some more or less nitpicky commentary; the any types are the thing I feel strongest about.

static-site/src/components/ListResources/Showcase.tsx Outdated Show resolved Hide resolved
static-site/src/components/ListResources/Showcase.tsx Outdated Show resolved Hide resolved
static-site/src/components/ListResources/types.ts Outdated Show resolved Hide resolved
@victorlin victorlin force-pushed the victorlin/list-resources-typing branch from dc2284d to b2c49a8 Compare May 24, 2024 02:43
@victorlin victorlin had a problem deploying to nextstrain-s-victorlin--wzhki5 May 24, 2024 02:43 Failure
@victorlin victorlin changed the base branch from master to victorlin/configure-static-site-typescript May 24, 2024 02:43
@victorlin victorlin force-pushed the victorlin/list-resources-typing branch from b2c49a8 to 6079a31 Compare May 24, 2024 02:50
@victorlin victorlin had a problem deploying to nextstrain-s-victorlin--wzhki5 May 24, 2024 02:51 Failure
@ivan-aksamentov
Copy link
Member

ivan-aksamentov commented May 24, 2024

Not strictly Typescript related and very subjective, but as you are going through the code conversion, how do you guys feel about also reducing the punctuation noise?

Functions don't have to be anonymous closures assigned to named variables:

-export const useShowcaseCards = (cards: Card[], groups: Group[]) => {
+export function useShowcaseCards(cards: Card[], groups: Group[]) {

P.S. Watch out for this and other runtime quirks, especially in d3 code.

I believe there exist a transformer tool or a eslint rule with autofix to apply this en masse.

Also, interfaces don't have to be aliases to anonymous object types:

-type ShowcaseTileProps = {
+interface ShowcaseTileProps {

P.S. Watch out for differences as well, notably when extending.

This one is a bit longer to type, but there's 1 less token (=) to parse with your eyes and we all use autocomplete and snippets nowadays, right?

@victorlin victorlin temporarily deployed to nextstrain-s-victorlin--wzhki5 May 24, 2024 17:04 Inactive
@victorlin victorlin temporarily deployed to nextstrain-s-victorlin--wzhki5 May 24, 2024 17:41 Inactive
@victorlin
Copy link
Member Author

victorlin commented May 24, 2024

Thanks @ivan-aksamentov and @genehack for the reviews – this kind of feedback and discussion is exactly what I'm looking for in this PR.

Base automatically changed from victorlin/configure-static-site-typescript to master May 28, 2024 17:43
@victorlin victorlin marked this pull request as ready for review May 28, 2024 23:40
@victorlin victorlin temporarily deployed to nextstrain-s-victorlin--wzhki5 May 29, 2024 22:44 Inactive
@victorlin victorlin force-pushed the victorlin/list-resources-typing branch from f7b770f to f2e4113 Compare May 31, 2024 18:05
@victorlin victorlin temporarily deployed to nextstrain-s-victorlin--wzhki5 May 31, 2024 18:06 Inactive
@victorlin victorlin mentioned this pull request May 31, 2024
2 tasks
This makes imports flexible to .tsx file conversion.
@victorlin victorlin force-pushed the victorlin/list-resources-typing branch from f2e4113 to 9d7b112 Compare May 31, 2024 22:53
@victorlin victorlin temporarily deployed to nextstrain-s-victorlin--wzhki5 May 31, 2024 22:53 Inactive
@victorlin victorlin temporarily deployed to nextstrain-s-victorlin--wzhki5 June 3, 2024 18:50 Inactive
@ivan-aksamentov
Copy link
Member

Looking at the code a little more closely I see that there are some cases of unsound js code (1, 2). It is difficult or impossible to provide sound typings for unsound code. The usual problems (and both of the examples I highlighted) are related to accessing potentially a null pointer.

Another example I saw is the ref.current.parentNode type casting to HTMLElement. This is an assumption ("we know") which can be broken, making this potentially a null pointer access undetectable by the compiler.

Hence, in terms of methodology, in the process of conversion, there is need for either:

  • fixing the code
  • adding a typing hack until later

One should decide which direction to chose on a case-by-case basis. If a hack direction is taken (cast, bang etc.), it is better to clearly document why it is needed with one of the loud comments, e.g. FIXME(ts) or HACK(types) and explain why types could not be correctly written here, without blaming the silly compiler, e.g.

// FIXME(ts): `dates` can be empty,
// but this code assumes at least 2 elements.
// We need explicit checks here (or a utility function).

not

// Silly compiler... Such a sweet summer child...
// Let's make it happy. Where is candies?
dates.at(999999999)!.explode

This way it's easy to grep and fix it later. By contrast, solitary bangs and casts are hard to find if there is no additional marking.

There is a trade-off:

  • try to add typings with only minimal changes to the codebase (makeing it a refactoring)

  • make typings sound (which requires fixes to the runtime behavior)

One of the things I like the most about TS is that when you add it, it highlights all the bad code immediately. And once TS is in place, it prevents from writing whole classes of new bad code - they will simply not compile. And if type hacks don't pass code reviews, then the codebase slowly becomes magically better.

@victorlin victorlin mentioned this pull request Jun 3, 2024
4 tasks
@victorlin victorlin force-pushed the victorlin/list-resources-typing branch from 7b24562 to adc666a Compare June 4, 2024 20:43
@victorlin victorlin temporarily deployed to nextstrain-s-victorlin--wzhki5 June 4, 2024 20:43 Inactive
@victorlin victorlin force-pushed the victorlin/list-resources-typing branch from adc666a to 2ad11e7 Compare June 4, 2024 20:59
@victorlin victorlin temporarily deployed to nextstrain-s-victorlin--wzhki5 June 4, 2024 20:59 Inactive
@victorlin victorlin force-pushed the victorlin/list-resources-typing branch from 2ad11e7 to aae874c Compare June 4, 2024 21:06
@victorlin victorlin temporarily deployed to nextstrain-s-victorlin--wzhki5 June 4, 2024 21:06 Inactive
@victorlin
Copy link
Member Author

I've applied suggestions and cleaned up the commits – this is now ready for re-review.

Copy link
Member

@jameshadfield jameshadfield left a comment

Choose a reason for hiding this comment

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

Adding types without changing too much code isn't easy, and I've no doubt the types will help development, so thanks for this work @victorlin!

No blocking changes, just questions. I'm surprised how many bangs were needed (I gave up making in-line comments!) and for my own learning I'd be interested to know if that's just the way it is or if it's an indication that the style of code used wasn't great and a better design is wanted. (I'm not saying a refactor / rewrite is worth our effort for this code at this point.)


// add history if mobile and resource is versioned
let history: React.JSX.Element | null = null
if (!isMobile && resource.versioned && resource.updateCadence && resource.nVersions) {
Copy link
Member

Choose a reason for hiding this comment

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

[not a request for changes] in lieu of using types to differentiate versioned vs unversioned resources, I wonder if a helper function would be clarifying here, e.g. (resource: Resource) => boolean, or is there more nuance around "versioned" vs "unversioned" than I remember?

Copy link
Member Author

@victorlin victorlin Jun 5, 2024

Choose a reason for hiding this comment

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

The previous code used presence of resource.versioned to determine if resource.updateCadence and resource.nVersions are available, but that's operating under the assumption that those are only set if resource.versioned = true which is a loose coupling that the compiler is unaware of:

src/components/ListResources/IndividualResource.tsx:138:40 - error TS18048: 'resource.updateCadence' is possibly 'undefined'.

138           <TooltipWrapper description={resource.updateCadence.description +
                                           ~~~~~~~~~~~~~~~~~~~~~~

The proper way to handle the case when those attributes are unavailable is to check their presence directly.

I would go one step further and remove the versioned attribute since it's only use is here as an alias. Something like this: #894

A helper function would only provide the same loose coupling as the current versioned attribute.

const days = (d2-d1)/1000/60/60/24;
if (d.length < 1) throw new Error("Missing dates.")

const d1 = new Date(d.at( 0)!).getTime();
Copy link
Member

Choose a reason for hiding this comment

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

Why is this bang needed? the preceding line ensures that there is at lease one element in d. Similarly for the subsequent line (d.at(-1)).

P.S. there's a bug in the UI for datasets with a single date where we end up reporting "0 days". But that's not for this PR!

Copy link
Member

@ivan-aksamentov ivan-aksamentov Jun 5, 2024

Choose a reason for hiding this comment

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

Why is this bang needed? the preceding line ensures that there is at lease one element in d.

Compiler is not smart enough to figure this out from this kind of the conditional. Connecting .length to .at() is tough for a type system.

What it can easily figure out is null checks. So a cleaner way would be to check and throw after non-fallible array access:

const t0 = d.at(0)?.getTime()
if(isNil(t0)) { // careful to not exclude the legit value `0`
    throw ...
}
// `t0` is guaranteed to be `number` in this branch

I would go further. Finding first and last value is a common enough "algorithm" that I would introduce a utility function:

export function firstLastOrThrow<T>(a: T[]): [T, T] {
    // TODO: use .at() or lodash head() and tail() along with null checks 
}

This would make the code very pretty and safe, with all dirty details hidden:

const [t0, t1] = firstLastOrThrow(dates) // guaranteed to return a pair of numbers or to  throw

Copy link
Member

@ivan-aksamentov ivan-aksamentov Jun 5, 2024

Choose a reason for hiding this comment

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

Another thing is that this entire calculation is wrong. For example, not all years have 365 days. Date-time calculations is a very hard topic, and it is somewhat unreasonable to try and do on the spot. There are specialized high-quality libraries exist to do this correctly, e.g. luxon (continuation of moment.js). And then for human durations there are also small libs (e.g. this). Not even talking about that calendars and durations are different in different cultures.

Copy link
Member

Choose a reason for hiding this comment

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

Another thing is that this entire calculation is wrong.

(Let's break this out to a separate conversation if we want to continue discussing it -- I am aware how fraught date parsing is but I'll argue that the data this function returns is (purposefully) vague such that the overall function is correct.)

Copy link
Member Author

Choose a reason for hiding this comment

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

Creating separate issues to continue these discussions: #892, #893

@@ -164,7 +178,8 @@ function _draw(ref, data) {

/* Create the x-scale and draw the x-axis */
const x = d3.scaleTime()
.domain([flatData[0].date, new Date()]) // the domain extends to the present day
// presence of dates on resource has already been checked so this assertion is safe
Copy link
Member

Choose a reason for hiding this comment

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

[not a request for changes] do you understand why this bang is needed? Is it a limitation of how types flow through d3, or is it how we're using it?

Copy link
Member

@ivan-aksamentov ivan-aksamentov Jun 5, 2024

Choose a reason for hiding this comment

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

Bracket notation returns undefined if accessing out of bounds (IIRC), so the type of a is potentially undefined.

The problem with comments like this:

// presence of dates on resource has already been checked so this assertion is safe

is that 3 months from now a fresh intern comes and inserts new code between the check and the assertion (not knowing that this assertion exists - it's hard to find even when looking for it specifically) and then 💥. Though, to be fair, it did not happen with js previously (due to lack of interns?)

Again, a small wrapper would make it safe and clean:

export function at<T>(a: T[], index: number): T {}

If you can find a fallback value, then something like this might work:

flatData[0]?.date ?? new Date()

This cannot fail and requires no hacks.

Continuing with the wrapper (though not directly applicable here sadly):

export function at<T>(a: T[], index: number, fallback?: T): T {}

Another option, although more involved, is to write a custom array type which always returns value or throws and never returns undefined. There might be libraries implementing non-empty arrays.

Copy link
Member Author

Choose a reason for hiding this comment

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

flatData is an array that could have a length of zero. In that case, flatData[0] is undefined and flatData[0].date would be an error. This is caught by the rules strictNullChecks + noUncheckedIndexedAccess. The initial TypeScript feature request for this is good context: microsoft/TypeScript#13778

The added comment describes the assumption of previous logic that's necessary for this bang to be valid, however it's fragile as @ivan-aksamentov mentions above.

Creating an issue to explore alternatives: #892

const sep = "│"; // ASCII 179
resources.forEach((r, i) => {
let name;
if (i===0) {
name = r.nameParts.join(sep);
} else {
let matchIdx = r.nameParts.map((word, j) => word === resources[i-1].nameParts[j]).findIndex((v) => !v);
let matchIdx = r.nameParts.map((word, j) => word === resources[i-1]?.nameParts[j]).findIndex((v) => !v);
Copy link
Member

Choose a reason for hiding this comment

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

[not a request for changes] similar to other comments, I'm surprised this is needed here - is this because tsc doesn't know if resources has been mutated underneath us, or because the if/else isn't informing the compiler that i>0?

Copy link
Member Author

Choose a reason for hiding this comment

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

From 4.1 release notes which added noUncheckedIndexedAccess:

One consequence of using noUncheckedIndexedAccess is that indexing into an array is also more strictly checked, even in a bounds-checked loop.

function groupsFrom(partitions: Partitions, pathPrefix: string, defaultGroupLinks: boolean, groupDisplayNames: GroupDisplayNames) {
return Object.keys(partitions).map(groupName => {
// groupName will always be a valid key based on map() above
const resources = partitions[groupName]!;
Copy link
Member

Choose a reason for hiding this comment

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

[not a request for changes] I guess the more ergonomic way is something like the following?

Object.entries(partitions).map(([groupName, resources]) => {

Is this assertion needed because the compiler isn't smart enough to know that group name is taken from the valid keys of partitions, or because mutability means that a key may have been removed from partitions before the map function gets that key?

Copy link
Member

@ivan-aksamentov ivan-aksamentov Jun 5, 2024

Choose a reason for hiding this comment

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

It's simply because operator [] returns T | undefined. It does not care about any conditions. Here, you could try and annotate groupName: typeof keyof Partitions and then it might work. But not sure. Also it's not needed if iterating entries or just values instead.

This example demonstrates how modern, thoughtful code compiles with no additional effort, while more hacky code does not and requires even more hacks to "make compiler happy".

I would also explore a possibility of using partition() from lodash [docs] here, to avoid reinventing a well known algorithm. It might be nuanced though, and not immediately obvious. But could potentially be a one- or two-liner. I might be confusing this code with some other code

Copy link
Member Author

Choose a reason for hiding this comment

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

@jameshadfield I got confused and didn't realize that [groupName, resources] is properly typed as long as the function is defined inline within Object.entries().map(). Changed 2aeb155 to 34a3140 which removes the need for the bang (reflected in force-push).

Inline the functions and use individual variables for each part of the
two-tuple returned by Object.entries().
Non-exhaustive attempt to add types. Note that with noImplicitAny
disabled, there is still a decent chunk of untyped variables.
- replace generic 'data|state' with 'card|resource|group'
- replace generic 'error' with 'data fetch error'
- replace 'modal' with 'modal resource'
These "worked" in normal JS under expected usage but raise errors by the
TypeScript compiler which generally catches unhandled edge cases. Each
has been addressed with inline reasoning.
The alternative of turning off noUnusedLocals is not desirable since we
still want to catch unused imports which is done by the same rule. This
is the same reason why no-unused-vars is enabled in ESLint with
exceptions in the lines below each change.
@victorlin victorlin force-pushed the victorlin/list-resources-typing branch from aae874c to 7c750e2 Compare June 5, 2024 18:14
@victorlin victorlin temporarily deployed to nextstrain-s-victorlin--wzhki5 June 5, 2024 18:14 Inactive
@victorlin
Copy link
Member Author

Sorry that c9788e7 left you with so many (valid) questions @jameshadfield. Ideally that should've been broken into multiple commits with more detailed reasoning. At this point I'll just merge – happy to discuss other details with you later.

Also thanks @ivan-aksamentov for your advise here.

TypeScript incoming!

@victorlin victorlin merged commit 6b9079d into master Jun 5, 2024
7 checks passed
@victorlin victorlin deleted the victorlin/list-resources-typing branch June 5, 2024 18:27
@victorlin victorlin mentioned this pull request Jun 14, 2024
2 tasks
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.

6 participants