Page MenuHomePhabricator

Evaluate use of TypeScript in the new Vue component library
Closed, ResolvedPublic

Description

The in-progress, experimental WVUI library is written in TypeScript, which was implemented after the evaluation completed in T249658. Now that the Design Systems Team is managing WVUI, we'd like to extend this evaluation and focus specifically on whether we should continue to use TypeScript in the library.

Disclaimer: I'm relatively new to TypeScript. Opinions from those who have used it a lot and those who are new to it are both appreciated.

Conclusion

"Codex", our new shared component library, will write Vue components in Typescript directly. You can see the current setup (subject to further refinement) here: https://github.com/wikimedia/vue-component-library/blob/main/packages/vue-components/tsconfig.json. The project is currently following the recommended configuration for Vite and Vue.js in TS.

In regards to the learning curve, we will work to provide documentation and help to contributors who are new to the language.

First, some notes

  • WMDE uses TypeScript in Wikit, for reasons outlined in this decision record.
  • There are various claims out there that attempt to quantify the benefit of using TypeScript or statically typed languages in general in terms of bug reduction: I've seen claims that using TypeScript can catch 15% or 20% of bugs, that TypeScript could have prevented 38% of bugs for Airbnb, and that statically typed languages have fewer bugs to a degree that is statistically significant but not functionally significant. All of these estimations have their flaws, so I'm not going to try including quantitative data in the pros or cons.

Benefits of TypeScript

There are some compelling reasons to use TypeScript, especially in a library like WVUI (and a future component library shared by more than just the Wikimedia Foundation), in terms of ease of maintenance, consistency of code in the long term, and IDE benefits for end users:

  • Static typing
    • More bugs are caught on code compilation, rather than at runtime
    • In theory, refactoring and maintenance is easier
    • Fewer tests needed since there is no need for additional testing of types
    • Basically eliminates the need to catch type errors during code review (or worse, when debugging a production bug)
  • More explicit typing
    • Compared to JSDoc, types in TypeScript are much more specific and illustrative (e.g. an interface representing the exact shape of an object vs. Object)
  • Incredibly robust IDE support
    • Integration with VSCode provides code completion, hover info, signature info, and code snippets
    • Code completion speeds up development (both of the library itself and for users of that library), less jumping to source files
    • In addition to catching errors on compile, with a properly configured IDE, errors are evident immediately
    • Better refactoring support
  • Widely used in and loved by the greater front-end community

Downsides of using TypeScript:

  • Creates a barrier to entry
    • While TypeScript seems straightforward and sensible, getting the details of the syntax right in even moderately complex circumstances can be challenging and time-consuming. Though less code review is noted as a benefit above, the Design Systems Team has spent a non-trivial amount of time during code review trying to figure out how to properly use TypeScript in certain circumstances (e.g. in a Vue template)
    • The code is more difficult to read for those who are not familiar with TypeScript
    • TypeScript is not yet a common part of the MediaWiki ecosystem
    • Configuration can be tricky, too, see this issue and resolution as an example from WMDE's Wikit
  • Vue support of TypeScript isn't complete and is especially lacking in Vue 2
    • Type checking in templates is relegated to Vetur (and is only experimental at the moment)
    • Combining Vue prop types and TypeScript is clumsy, see the documentation
  • Increases verbosity
    • TypeScript code is objectively longer than JavaScript that does the same thing
  • Can seem redundant or unnecessary
    • Type definitions are supposed to be "self-documenting" but can occasionally be redundant and are sometimes no better than JSDoc
    • Vue's PropTypes already offer static typing

Reducing the barrier

If we do decide to stick with TypeScript, we should think through ways to reduce the barrier to entry and to decrease time spent fighting with complex types and configuration. Some ideas:

  • The combination of JSDoc and TypeScript can be confusing and seem redundant. For example, what if I use TypeScript to define the type of a function parameter, but I also need to add a brief sentence describing the param? Do I have to use both JSDoc and TypeScript? We should figure this out and come up with a documented standard to avoid confusion.
  • Ensure that all of the configuration associated with TypeScript is complete, ideal, and maintainable
  • Don't minimize the learning curve. When I did this research I came upon so many people saying that if you know JavaScript you already know TypeScript, that there's no learning curve, and that it's easy. That simply is not the case and isn't helpful to newcomers. Let's acknowledge the learning curve and provide ways for those new to TypeScript to onboard to it as painlessly as possible
  • Make sure everyone is using the Vetur extension for VSCode

Event Timeline

From my personal perspective, and my experience with OOUI that was meant to be a shared library used by teams to create components, I am extremely intrigued by the potential benefits that TypeScript can offer with the IDE hints and developer-help that you've mentioned above. I think it might be worth exploring whether it's worth using TypeScript if not in the entire wvui library, then even only on the "lower/common" underlying code that others will rely on -- llike mixins and base components.

I do not, however, have much experience with TypeScript so what I say above is merely support to exploring this feature and a point to make about focusing the development of the library as one that helps others develop as clearly and efficiently as possible with the least amount of pain. I'd prioritize that in the consideration, in my opinion.

That said, I do think there's a missing bullet point in the potential downsides -- however, I'm not entirely sure if this point is not outdated, so I would like to merely raise it as a point to explore and verify:

Using TypeScript does mean increasing the abstraction of code. That is, it means relying on the TypeScript compiler, for better and worse. We have some experience in previous work where the "norms" online differed from the norms we in Wikipedia etc are focusing on, like supporting certain browser capabilities or languages, etc. There were cases where we created polyfills and special "hacks" that use vanilla JS to overcome some of those barriers.

To be fair, this was also true for jQuery that is judgmental in how it performs some actions -- however, since jQuery isn't compiled, we could simply use vanilla JS in certain places instead of jQuery and resolve the edge cases. This will not really be possible with TypeScript if it's compiled; it's TypeScript or not in the entire file.

I am not entirely sure if this is relevant anymore, to be fair here. Web technology has come a long long way since our hacky "fixes" and browser-hacks in jQuery and vanilla JS, but since we've encountered those before, and since the larger web community tends to have some blindspots that we consider important to support, I would at the very least recommend adding this as a point to explore. How many "corrections" would we potentially need to do to the TypeScript compiler? Can we do them on our own local instance? Are these even relevant at all anymore considering the support matrix with Vue? Etc.

I suspect that some of this may not actually be a problem, but I think it's at least worth mentioning and potentially looking into.

Great summary! Thank you for doing this exploration!

One thing I've noticed while doing development in WVUI is that the TypeScript integration with Vue 2 behaves strangely sometimes. For example, it only works if you explicitly specify the return type for every computed property function. If you don't specify a return type on any one computed property function that accesses this.something, then type inference breaks for all props and computed properties in all computed property functions.

For example:

export default Vue.extend( {
	name: 'WvuiFoo',
	props: {
		value: {
			type: String,
			default: ''
		}
	},
	computed: {
		doubleValue() : string {
			return this.value.repeat( 2 );
		},
		halfValue() : string {
			return this.value.slice( 0, this.value.length / 2 );
		},
		doubleAndHalf() : string {
			return this.doubleValue + this.halfValue;
		}
	}
} );

This passes type checks, but if you remove : string from one of the three computed property functions, you get all sorts of confusing typing errors in the other two (but not in the one you removed it from, except if it uses other computed properties).

Example errors:

  • Property 'repeat' does not exist on (() => any) | ComputedOptions<any>
  • Operator + cannot be applied to types (() => any) | ComputedTypes<any> and (() => any) | ComputedTypes<any>
  • Property 'doubleValue' does not exist on type CombinedVueInstance<Vue, unknown, unknown, unknown, Readonly<{ value: string }>>

We should document the requirement to add return types for all computed properties and what the errors that you get when you forget to do that look like, because people are likely to run into this often.

Compared to JSDoc, types in TypeScript are much more specific and illustrative. e.g. an interface representing the exact shape of an object vs. Object.

(I, too, have relatively little experiencce with TypeScript as of now.)

I believe TypeScript's JSDoc mode does support defining full shapes as well. Right?

I believe TypeScript's JSDoc mode does support defining full shapes as well. Right?

Yes, it is possible to define far more than just type "Object" or so, see e.g. the examples for param

Barrier to entry: Two thoughts on this.

  1. The barrier for people being used to JavaScript is definitively there, and to get the type definitions right feels like an additional language (to me, at least) . Aside of that, however, the height of the metaphorical barrier depends one the style of TypeScript that is written. One can go with a relatively easy to understand "ES6 with types" that can compile in almost the same code, minus the type definitions and run directly in browser, if wanted. Or one can use all the magic: namespaces, tuple, decorators. This could be reflected in the code style and be a point in "reducing the barriers".
  2. I agree with @Mooeypoo’s "Using TypeScript does mean increasing the abstraction of code. That is, it means relying on the TypeScript compiler, for better and worse." However, viewed in context of popular other extensions of JavaScript, TypeScript is quite tame; Codebases using JSX and several very future babel extensions are easily as hard or harder to read and are based on a more complex setup. And due to the long time without a native module system, ES6 imports are still not normal in JavaScript – most code de-facto relies on some compilation for them even if one just develops locally (And I don't like it).
  1. I agree with @Mooeypoo’s "Using TypeScript does mean increasing the abstraction of code. That is, it means relying on the TypeScript compiler, for better and worse." However, viewed in context of popular other extensions of JavaScript, TypeScript is quite tame; Codebases using JSX and several very future babel extensions are easily as hard or harder to read and are based on a more complex setup. And due to the long time without a native module system, ES6 imports are still not normal in JavaScript – most code de-facto relies on some compilation for them even if one just develops locally (And I don't like it).

Yeah, to clarify, my comment about the abstraction layer was not meant to be a show-stopper at all. I don't think it prevents us from using TypeScript -- I just think we should be aware of potential challenges with this, and see if there might be a need to provide some mitigation strategies. I also didn't mean to compare it to other platforms, only to point to a potential issue to look into.

I think my concern can be boils down to this:

When a process is opinionated (and compilers are) then the actions it performs may come at the expense of things that the internet-at-large considers "minor Edge Case", that, in our products and context, turn out to be not so edge-casey at all. We've encountered this before.

I think we should look into whether this is a problem and whether we can, if we ever need to, mitigate this (can we override some compilation features, or will each 'edge case' will take months until it gets upstreamed? etc)

I don't mean it as a blocker, just as a question I think we need to look into and potentially prepare for.

@Mooeypoo it seems your concern is primarily about possible drawbacks of using the typescript compiler and the javascript it produces, not about TypeScript as a programming language - is this right? I admit the difference is maybe only formal, and it does not invalidate your points.

Do you happen to have an example of what those edge cases could be? You did say you don't have much experience with TypeScript above, and it seems to be a relatively new topic to most of us. Maybe you still point out some of the things you talked in the bit quoted below, that could potentially be of relevance in the TypeScript case as well? Thanks.

We have some experience in previous work where the "norms" online differed from the norms we in Wikipedia etc are focusing on, like supporting certain browser capabilities or languages, etc. There were cases where we created polyfills and special "hacks" that use vanilla JS to overcome some of those barriers.

Correct. My "concern" is simply about the added abstraction of a compiler in javascript.

I mentioned above, even though I have no actual TypeScript experience, I am generally in favor of looking into using it in the base component library *because* wvui is meant to be used by other code bases, extensions and tools, and I think the added benefit of developer tools (both the IDE hints and the stricter discipline it encourages) might actually help in the long run.

My only point is that we had in the past cases with jQuery (I will need to find those specific examples, but I believe some of them were about event listeners that were specific?) where jQuery's specific internal operation didn't quite cover an edge case we needed, so we wrote a couple of lines of vanilla javascript.

That won't be possible in TypeScript.

I can find examples of needed, but off the top of my head, I believe there were some issues with checkboxes and the events they emitted (but I'll need to look it up if you want specifics?) and we had to occasionally override the event handling in vanilla js.

My main point here is that we should see if this concern even HAS any merit (it might not! I just wanted to not ignore it) AND to check whether we will have the possibility of occasionally overriding some behavior locally for us, if needed.

I didn't mean to make this point a blocker, I just wanted to point out that this will be basically the first time we will depend on a library to abstract javascript for us with less flexibility than we had before, and since wvui is intended to be used everywhere, I thought the point deserved mention and potential looking into.

I hope this clarifies the concern?

Another trick I learned yesterday: if you access a ref, like this.$refs.foo, TypeScript doesn't know exactly what type it is, and infers its type as Vue | Element | Vue[] | Element[]. That's not a very useful type if you want to call methods or access properties, but you can tell TypeScript what the right type is.

If you have a ref that's an HTML element, e.g. <input ref="foo" />, you can force it to the corresponding HTMLElement subclass, e.g. ( this.$refs.input as HTMLInputElement ).focus().

If you have a ref that's a Vue component, e.g. <wvui-another-component ref="bar" />, you can tell TS what it is as follows: ( this.$refs.bar as InstanceType<typeof WvuiAnotherComponent> ).someMethod() (where WvuiAnotherComponent comes from import WvuiAnotherComponent from '../another-component/AnotherComponent.vue'.

It seems clear that we want to use Typescript as we move forward with a shared component library. But there is still the question of the best way to use Typescript.

Since we're talking about a shared library here, I think it makes sense to pay especially close attention to the experience of the end-user who is consuming the library's components in the context of an application. In this scenario, library components like WvuiButton would probably be used side-by-side with application-specific components that have been developed locally. The application may be written in TS or in plain JS.

With a properly-configured editor like VSCode, developers can get some helpful hints (aka "intellisense") when they are working with JS or TS files. For example, type definitions from NPM modules will get picked up and displayed while the user types. WVUI currently does this, but I think that we are not configuring things 100% correctly. Here's what you will currently see in VSCode if you import WvuiButton from NPM:

Screen Shot 2021-06-08 at 3.15.56 PM.png (620×1 px, 125 KB)

ExtendedVue, unknown – we have feedback but it's a little cryptic. I would argue this information is not super helpful to the end-user in its current form.

In a perfect world, a consumer of our shared library would be presented with the complete type information of all custom properties that have been added to the component – they'd see that there is an action property which takes a String value, that only certain values are allowed (primary, destructive, etc), that certain events are emitted, etc. Unfortunately I'm not sure this is 100% possible, at least not in Vue2. WVUI contains extensive type annotations for component properties in the library source code, but it's not clear to me that these end up getting exposed to end users in any meaningful way. For things like prop type validation, we may want to rely less on Typescript and more on Vue's own prop validation tools since those will be available at runtime.

Vuetify (which we could consider to be a "gold standard" as far as Vue UI libs go) deals with this by simply declaring that each of its own components implements the standard Vue Component interface (see here) – their declaration file also seems to be manually maintained, for what it's worth.

Just knowing that something is a Vue component is better than nothing, but it's certainly not ideal from the end-user's perspective. Fortunately, it's possible to provide additional information in an editor like VSCode by relying on Vetur. Specifically, Vetur can be configured to provide suggestions and auto-completion within Vue component templates when a given component is being used. Developers can see a list of available properties, their descriptions, and which values are valid for a given property.

This can be done by adding a "vetur" key to package.json and referencing some additional files that list tags and attributes:

{
  "vetur": { "tags": "./tags.json", "attributes": "./attributes.json" }
}

More information about this can be found in the Vetur docs; this blog post also did a good job explaining how to get this working. It may be possible to automatically generate this data from existing TS or JSDoc annotations, but I'm not 100% clear on how to do that yet.

TLDR / take-aways:

  • Within our shared component library, a main (perhaps the main) goal of using TypeScript should be to improve the dev experience for library consumers
  • We should make sure the type definitions we ship for components are accurate and useful (there may be some limitations to what we can do here)
  • We may want to prefer relying on Vue's own tools for prop validation (instead of relying on Typescript) so we get the benefits at runtime
  • We should look into ways to automatically generate component data for Vetur from our existing code annotations; here's how Vuetify does it

I've created a dedicated task for some of the typing improvements I mentioned above here: T284782

The title of this task refers to WVUI, but based on the outcome of the developer summit ("create a new shared library"), I want to talk about ways to use typescript in the context of the new project we are about to start.

Last week, I spent some time working on a demo tabbed UI component. I wanted to get a feel for how to write Vue 3 components using the composition API, Typescript, and some other new technologies.

Part of why I chose a "Tabs" component for the demo was the fact that this component requires some tricky parent-child communication: an arbitrary number of <Tab> child components are provided as slots in a parent <Tab> component, but the parent needs to get data out of the children to display tab headings, and then tell the children which tab is active at any given time. I ended up using the provide/inject feature in Vue to handle this.

Originally I tried to do everything in TypeScript. Unfortunately this proved very unwieldy and I eventually had to abandon this approach. I like to work in an iterative fashion, adding or removing various properties and seeing what happens, then tweaking and testing again. REPL style, basically.

I was still trying to figure out the shape of the data I wanted to provide to the child components, but TypeScript expected me to know exactly what I wanted before I wrote the code. Make a small change and the editor lights up with red error warnings until you update your type definitions, etc.

When I switched back to JS, I was able to "sketch" out different approaches in my code again until I arrived at something that worked. But I still wanted some measure of type-checking.

Type checking with JSDoc

Using JSDoc to type-check my code turned out to be easier than expected. JSDoc supports @type and @typedef tags in code doc-blocks. It also allows you to import types defined elsewhere.

I ended up adding a single types.d.ts file to my project that contained all my custom definitions:

types.d.ts
export interface TabData {
	id: string,
	label: string,
	isActive: boolean,
	disabled: boolean
}

export interface TabsData {
	[key: string]: TabData
}

Then in my components, I could write code like this:

/**
 * Data that will be exposed to the template and injected into the child Tab
 * components so that they can determine their appearance and behavior. Slot
 * content is always an array (of vdom nodes), but we want to reduce this to a
 * single keyed object for easier access to specific tabs.
 *
 * @type {import("../types").TabsData}
 */
const tabsData = reactive( useSlots().default().reduce( ( map, item, currentIndex ) => {
    // ...
}, {} ) );

Now every time I needed to reference tabsData, my editor (VSCode) was able to provide detailed hints about the properties and their types. By adding a //@ts-check comment at the top of my file, I could also get additional type validation in my JS component file.

Because Vue 3 itself is written in TS, most of the rest of my code also had good type information (which gets inferred automatically for the most part). Types only need to be added for things like custom data structures that we have defined.

I like how this approach preserves the readability of the code as well – in a lot of TS code I've seen, I feel like human readability has been sacrificed for machine readability.

For some additional type safety, a jsconfig.json or tsconfig.json file can be added to the project to check JS files automatically; this could probably be treated like a linting step in CI.

A good write-up about this way of working can be found here: https://austingil.com/typescript-the-easy-way/

TLDR; I'd like to propose that we consider using JSDoc for type checking in this new library rather than writing all of our code in TS directly. I think we'll gain most of the benefits without losing a lot of development velocity or code legibility.

AnneT renamed this task from Evaluate use of TypeScript in WVUI to Evaluate use of TypeScript in the new Vue component library.Sep 21 2021, 12:22 PM

Thanks @egardner for this excellent write-up!

I wasn't aware that JSDoc integrates with TS this nicely. That's really cool! While I can see a ton of benefits with this approach in many scenarios, I'm not fully convinced yet that it's the right choice for the component library.

You mentioned TS feeling unwieldy when prototyping and exploring different approaches. I definitely share this experience that the added strictness can slow down development in certain situations. This may be solvable by adding //@ts-nocheck (docs) to the top of the file, effectively telling TS to go away while you're trying out different things until you're happy with the overall approach and ready to add types. Have you tried something like this? I'm curious to hear your (or anyone's) thoughts on this, whether this is an acceptable workaround or not. IMO this makes the workflow very similar to writing plain JS first and adding doc blocks afterwards.

I feel like discarding TS for a gain in prototyping speed could have drawbacks in the long run. It is important, but ultimately more time will be spent on maintenance, and on developing applications consuming this code.

I really liked the blog post about gradually adopting JSDoc + TS you linked. The author concludes the article with some reasoning for JSDoc over TS in their context, but I don't think much of that applies to our situation. He writes:

I prefer the JSDocs approach for these reasons:

  • There’s no need for build steps. It’s just plain JavaScript.
  • Which means I can copy and paste code to any JavaScript project.
  • No new syntax so it feels easier (for me) to learn.
  • Less noise mixed into my code.
  • Development has been faster since there is no waiting for the compiler.

The first and last arguments don't apply as far as I know. We still need a build step for the shared component library for Vue SFCs, CSS pre-processing etc.

In my opinion "no new syntax" doesn't apply either as soon as we mix TS into JSDoc via .d.ts files, and advanced JSDoc is quite some syntax by itself. You would need to know which TS constructs work with JSDoc, and which don't. That probably makes it more syntax to learn than plain TS.

"Less noise" is equally debatable. It shifts the type hints into comments, but I'm pretty sure if we shoot for the same level of coverage with JSDoc that will end up more verbose than TS.

I think that if we're mainly concerned about creating documentation of public interfaces of the components, and improving editor support for consumers of the library, then JSDoc may be sufficient. If in addition to that we want type safety for the code base internally to eliminate a certain category of bugs and potentially make the lives of the library's maintainers easier, then I believe the benefits of TS outweigh the downsides.

Interestingly the arguments in favor of JSDoc fit a lot of our application code written as ResourceLoader modules really well, so this might be where the JSDoc + type checking in CI approach could really shine. That's a different topic though! :)

Thanks @Jakob_WMDE, I think this is a good summary of the benefits of going "all in" on TS. I think that our respective pros and cons here can be the basis for a good discussion next week.

I just posted a similar comment over in T288980, but I'm curious about the potential of Vue 3's new <script setup> syntax to make it easier to work with Typescript in component files; many of the benefits of this syntax are specific to TS.

Update

I spent a few hours today re-writing my demo code in Typescript. Even just converting already-working code was a bit of a struggle, but a lot of that is probably just my inexperience with the language.

One challenge was figuring out the best way to deal with potentially arbitrary content coming from the user inside of <slot> regions. If you're not careful this sort of thing can leak uncertainty across the rest of the file, forcing a reliance on typecasting. This can create a false sense of security since the TS code is not actually present at runtime, which is when many of these errors would occur (like a user providing invalid content inside of a slot).

For slot content, I think the best way to guarantee type safety is to add a guard clause, something along these lines:

const contents = useSlots()?.default?.();
if ( !contents ) {
	throw new Error( `One or more <tab> components must be provided` );
}

Throwing an explicit Error will allow subsequent uses of contents to exist in a type-safe way.

Provide/Inject (a way to pass data between 2 components) also required some special considerations for type safety; this blog post was one of the only sources of info I could find on how to properly do this in TS: https://logaretm.com/blog/2020-12-23-type-safe-provide-inject

egardner updated the task description. (Show Details)