TypeScript is now a first class citizen in Openverse frontend

Well, mostly

As of the merging of this PR in the WordPress/openverse-frontend repository, it is now possible to use native TypeScript for a lot of the code in OpenverseOpenverse Openverse is a search engine for openly-licensed media, including photos, audio, and video. Openverse is also the name for the collection of related code repositories that make up the project. for which we were previously using JavaScriptJavaScript JavaScript or JS is an object-oriented computer programming language commonly used to create interactive effects within web browsers. WordPress makes extensive use of JS for a better user experience. While PHP is executed on the server, JS executes within a user’s browser. https://www.javascript.com/.. The only exception to this are VueVue Vue (pronounced /vjuː/, like view) is a progressive framework for building user interfaces. https://vuejs.org/. single-file-components (SFCs)1. We will continue to use regular JavaScript with helpful but non-contractual2 JSDoc annotations in Vue SFCs; for the rest of our JavaScript we will slowly add type-checking via one of the two methods described below.

There are now two ways to add types to any given module. You can either:

  1. Include the JavaScript module directly in the tsconfig.json includes array, as has been done with use-event-listener-outside.js on this line. This prompts TypeScript to rely on JSDoc type annotations to type-check regular JavaScript.
  2. Rename the file from .js to a .ts. All TypeScript files are automatically included in our tsconfig.json and will be type-checked.

Please note, the second will require restarting your dev server as Webpack won’t be able to keep track of file extensions changing (it’ll get stuck looking for a now non-existent *.js file).

In general, the current contributors to Openverse’s frontend prefer to use native TypeScript over the JSDoc variant. In particular more of us know native TypeScript syntax than the intricacies of the JSDoc variant.

There is a Milestone in the WordPress/openverse-frontend repository to track progress on adding type checking to all the modules that would allow it. If you work on these issues, please note that it is best practice to reduce the number of runtime changes to the module when converting them to TypeScript; however, sometimes it is necessary to add additional runtime type-checks to satisfy the TypeScript compiler. Anticipate needing to do this.

Some specific techniques to keep in mind

There are some specific techniques I’ve found helpful for gradually adding TypeScript to an existing JavaScript project; in particular, much of this was learned during the ongoing effort to add type-checking to GutenbergGutenberg The Gutenberg project is the new Editor Interface for WordPress. The editor improves the process and experience of creating new content, making writing rich content much simpler. It uses ‘blocks’ to add richness rather than shortcodes, custom HTML etc. https://wordpress.org/gutenberg/ and its various packages.

Discriminated union type narrowing

This technique is useful when you get a discriminated union type from a function return and need to prove to TypeScript that you know what the type of the object is you’re working with. For example, take our MediaDetail type. Perhaps you have an array typed MediaDetail[] and would like to iterate over each and do something different with each media type.

The way to do this is to use the MediaDetail‘s shared frontendMediaType property. The presence of this property is what makes the MediaDetail type a discriminated (or “tagged”) union: the value of the frontendMediaType property allows the type checker to discriminate between the possible types that can be assigned to a MediaDetail. For example, because frontendMediaType on AudioDetail is typed as the string constant 'audio', the type checker will know that the following assertion will narrow the type of the object to the AudioDetail type:

const mediaDetails: MediaDetail[] = getMediaDetailArray()

mediaDetails.forEach((mediaDetail) => {
  if (mediaDetail.frontendMediaType === 'audio') {
    // In here, TypeScript will know that mediaDetail is an AudioDetail type
  } else {
    // Because MediaDetail can only by AudioDetail or ImageDetail (for now), in here TS will know that mediaDetail is an ImageDetail type
  }
})

Here is an example of the above in the TypeScript playground for you to play around with.

Type assertion functions

Sometimes we need to assert the type of something that is not starting from a discriminated union type. For example, variables typed as unknown need to be cast to a known type before properties on them can be accessed. To do this, we can use a type assertion function.

Note: Before getting too deep in the weeds of this tool, please keep in mind that under the hood a type assertion function is essentially a hard cast that is theoretically backed by a runtime “type check” (where type here usually refers to shaped types rather than nominal types). That means that there’s effectively no difference between a type assertion function and foo as unknown as WhateverIWant. TypeScript is going to take our word that our type assertion functions actually do the work to “prove” (in so far as types are provable in TypeScript) that we have the type we say we have. That means that we should carefully consider how we use this tool and also ensure that we cover the implementing functions with 100% unit test coverage. TypeScript will gladly accept the following function as a boolean type assertion, despite it being nonsense:

function isBoolean(o: any): o is boolean {
return typeof o === 'string'
}

So be careful! Anyway, back to the show…

The TypeScript project has a playground that describes this feature here. It’s kind of wordy, so I’ll summarize it here.

Essentially, if you can prove to yourself via some runtime type checks that a variable is a specific type, then you can let TypeScript know this through a special is return type annotation.

const isNumber = (o: any): o is number => typeof o === 'number'

That’s the simplest example. A more complex example could be the following:

const isPromise = (o: any): o is Promise => {
return (
o &&
typeof o.then === 'function' &&
typeof o.catch === 'function' &&
typeof o.finally === 'function'
)
}

You may also use the special keyword asserts to indicate to TypeScript that the function will stop execution in the parent context (probably by throwing an error but it could also be via process.exit in a Node context) if the runtime type check does not pass.

This is useful when you know code needs to fail to execute if a value is null or undefined for example:

export function assertIsDefined(val: T): asserts val is NonNullable {
if (typeof val === 'undefined' || val === null) {
throw new Error()
}
}

When you use this function, after calling assertIsDefined(foo), TypeScript will know that foo is not null or undefined.

Casting to const

Casting to const is an invaluable tool in TypeScript as it allows you to derive specific types from object and array definitions rather than general types. If you want to declare an array a tuple in TypeScript, you must use the as const cast.

const generalStringArray = ['one', 'two', 'three']

const tuple = ['one', 'two', 'three'] as const

The generalStringArray will have the inferred type of string[] where as tuple will have the much more specific type of ['one', 'two', 'three'] (literally, the type will be the same as the value, how amazing is that?!).

This is particularly useful (and in fact necessary) to use for typing a dependency array for the watch function. Without casting the dependency array to const, TypeScript will infer a mixed type for each element of the array passed to the watcher callback that combines all the constituent parts of the dependencies.

Please open this TypeScript playground link to see a live example of this problem and how to solve it by casting to const. Note how in the first watch example, each of the values in the callback are typed as a combination of the types of each element of the dependency array. In the second, which uses as const, TypeScript is told to determine the type of the values in the callback by position as in a tuple where each element is typed separate from the others.

Packages to know about

@types/nuxt

This package is already included in our dependencies and houses all of the various Nuxt types like the type for context and app. Extending the types in this package via interface merging allows us to describe our additional modifications to Nuxt’s context. For example, we currently add annotations for the $ua and i18n extensions to Nuxt’s context. When typing anything that uses the useContext composable, it may be necessary to add further extensions to those types.

vue

This is of course one of the coreCore Core is the set of software required to run WordPress. The Core Development Team builds WordPress. packages of our frontend application. It also houses many core types for Vue, like the Component type.

@nuxtjs/composition-api

You likely already are aware of this package, but please note that it includes a wealth of Vue related types for the Composition APIAPI An API or Application Programming Interface is a software intermediary that allows programs to interact with each other and share data in limited, clearly defined ways.. In here you will find the Ref type for example.

utility-types

The utility-types package includes a helpful set of types for dealing with common TypeScript scenarios. This is not currently included in our dependencies but is a reliable source of utility types should we need them. Check this package first before trying to write super-complex generic mapped types.

More helpful resources

TypeScript playground

The TypeScript playground (https://www.typescriptlang.org/play) is very helpful for collaborating with others on type-related problems. I’ve found it particularly helpful in that it forces me to narrow the problem I’m having to the bare-minimum example of it. Often times this practice alone can help me resolve an issue I’m having or at least see it more clearly.

Type challenges

If you’re looking to flex new, growing, or even strong TypeScript muscles, the type-challenges repository is a great resource. It’s a collection of small (though almost never simple) TypeScript challenges that force you to explore things like type inference, generic types, mapped types, and other intermediate to advanced TypeScript features.

I will say that even some of the so-called “easy” challenges are not for the faint of heart! They can be quite the brain teasers and sometimes pretty frustrating but so satisfying to find solutions for. The community is also quite active in sharing their solutions and this is a great way to pick up new tricks from TypeScript wizards.


1 There’s a little bit more tooling we’d have to do to get Vue files able to be typed, in particular around distinguishing Vue templates from ReactReact React is a JavaScript library that makes it easy to reason about, construct, and maintain stateless and stateful user interfaces. https://reactjs.org/. JSX (it appears TypeScript gets quite confused about this). Storybook, unfortunately, is the culprit here as its dependencies pull in the @types/react package which pollutes (at least from a Vue perspective) the global JSX namespace with React specific types. For example, React’s type for the class attribution on an HTMLHTML HTML is an acronym for Hyper Text Markup Language. It is a markup language that is used in the development of web pages and websites. element does not allow for Vue’s array and object classname binding syntax. One solution would be to switch to the vue-tsc library, and indeed we will do that when time comes for us to upgrade Nuxt via Nuxt bridge; but for now plain old TypeScript is fine for our purposes.

2 I use the phrase “helpful but non-contractual” here to distinguish from the “contractual” aspect of actually type checking a file. The JSDoc annotations in SFCs are helpful to indicate to the reader (and likely to their editor) what the intention is; but they are non-contractual in that they are not checked for correctness or enforced by the TypeScript compiler. You may still notice some editors giving you TypeScript errors in this file (VSCode will do this, for example) but they will not be enforced by the CI type-checking.