CesiumJS Adds Official TypeScript Type Definitions
With today’s 1.70 release, CesiumJS now ships with official TypeScript type definitions!
TypeScript definitions has been a long requested feature. While the community has done a yeoman’s job of supporting various manual efforts, the most popular of which is @types/cesium, the sheer scale and ever-evolving nature of Cesium’s codebase makes manual maintenance a never-ending task. The official definition file, Cesium.d.ts, clocks in at over 42,000 lines and is a whopping 1.9 MB.
Even if you are not a TypeScript user, the nature of this work improves the correctness and completeness of CesiumJS API Reference documentation and enables better intellisense support in IDEs that can apply TypeScript definitions to inferred types, making it a big win for the entire CesiumJS community.
Updating to CesiumJS 1.70 will automatically take advantage of type checking in your TypeScript applications. We make use of the types field in package.json so no additional configuration is required in most cases. However, if you import individual CesiumJS source files directly, you’ll need to add "types": ["cesium"] to your tsconfig.json in order for the definitions to be picked up. If you were previously using @types/cesium, you can remove it.
Official support from the CesiumJS team means that the most up-to-date and correct definition files will ship with each release. It also means that TypeScript support will be officially tracked as part of the CesiumJS GitHub repository. If you find a bug using CesiumJS with TypeScript, please open an issue or better yet, a pull request, to address it. If you just have questions about CesiumJS/TypeScript or need help debugging your project, ask on the community forum.
If you’re using your own custom definitions or @types/cesium and you’re not yet ready to switch, you can delete Source/Cesium.d.ts after install. TypeScript tooling will then fall back to the next set of CesiumJS type definitions that it finds.
Diving Deeper
While we’re thrilled to finally be officially supporting TypeScript, getting there took some effort. Initially we explored 3 options:
Manually maintain definition files
We could manually curate and maintain our own TypeScript definition files as part of the CesiumJS codebase, most likely as a separate definition file per JavaScript file to make it manageable, i.e. Cartesian3.js gets Cartesian3.d.ts. This would be the easiest to implement at a technical level, but it would be a huge burden to maintain and too easy for the files to accidentally get out of sync.
Also, we didn’t want to include just the declaration interfaces but also our inline documentation too so that users get the full benefit of intellisense. This was always our option of last resort, but it’s where we would have ended up if it turned out to be the only viable choice.
Port CesiumJS to TypeScript
You may be surprised to hear that we actually evaluated rewriting all of CesiumJS in TypeScript. This would be a massive improvement for TypeScript developers and would also be a net win for the CesiumJS maintainers and codebase. In addition to strong type checking, it would put us on a fast track to using modern conventions such as template literals, arrow functions, and async/await, which we currently do not allow in the CesiumJS codebase for compatibility and tooling reasons.
Unfortunately, the level of effort involved and the amount of work this would take did not make it an attractive option in the short term. This option is still on the table, but just like the massive ES6 migration we did last year, it will require a lot of careful planning, research, and infrastructure work to do properly.
Generate definition files using the TypeScript compiler
Starting with TypeScript 3.7, the compiler can compile JavaScript code with JSDoc annotations and generate type definition files for us. This approach completely removes the need to manually maintain .d.ts files and has the added benefit of validating and improving our own JSDoc annotations, since they need to be accurate in order to generate correct type definitions. Needless to say, this option was very attractive to us and we decided to run with it after some initial prototyping showed that it could work.
We actually spent several weeks on this approach, with Marco Hutter pitching in to do a lot of the documentation fixes and source tweaks to make the compiler happy. Early work was very promising. As expected, it exposed errors and inconsistency in our JSDoc annotations and to a lesser extent, the CesiumJS API, which we fixed. Unfortunately, we soon hit a wall.
Relying on the TypeScript compiler meant that we didn’t have a lot of options when it did something wrong or unexpected. While the compiler used JSDoc annotations in some cases, there were many cases where it relied on its own type inferencing instead and provided no way for us to override it. It also completely ignored much of the JSDoc, such as anything used within the context of Object.defineProperties, and exposed all of our private underscore variables as part of the definition. This caused us to start bending the CesiumJS codebase in ways we weren’t comfortable with, just to make the TypeScript compiler happy. We floated the idea of trying to modify the TypeScript compiler itself, but then we would have to dig into the compiler code and we weren’t even sure what would be accepted by the maintainers or how long the process would take. Ultimately, our favorite solution turned into a longshot gamble and we lost faith in this approach.
We aren’t afraid of failure at Cesium—failure provides valuable information if you are willing to accept it. After a few weeks of the TypeScript compiler approach, we realized it was not going to work. Even if we pulled out every hack in our bag of tricks, the end result would be hard to maintain and would fundamentally change how we developed CesiumJS. We walked away and headed back to the drawing board. There had to be another option we hadn’t considered.
The drawing board
It turns out that we were so excited about TypeScript having official JSDoc support that we completely overlooked a similar option, tsd-jsdoc. tsd-jsdoc is a plugin to JSDoc that generates TypeScript definitions from JSDoc output. This makes it very similar to the TypeScript compiler approach but provides greater control over the generated type definitions.
tsd-jsdoc does not parse JavaScript directly and instead relies on the abstract syntax tree (AST) generated by JSDoc. This means it does not suffer from the type inferencing problems or lack of JSDoc completeness which made the TypeScript compiler approach a failure. If we could express the type using JSDoc annotations, then it would be exactly what we wanted it to be in the type definition file.
We had learned a lot from our previous failure with the TypeScript compiler approach, so we were able to do a full feasibility evaluation fairly quickly, and all of the the issues with our existing JSDoc being incorrect still applied. Things progressed faster than we could possibly have imagined, and we knew we had found our solution.
As developers, sometimes we get so wrapped up in a particular tech that we tend to overlook other options. In this case, community member @bampakoa had even contributed pull requests to both CesiumJS and tsd-jsdoc last year to make them more compatible. We already knew tsd-jsdoc existed, but we left it out of our initial evaluations because we assumed the TypeScript compiler option was going to be better, and we accidentally put blinders on because of it.
Post-processing and validation
While tsd-jsdoc output is fairly high quality out-of-the-box, we do some additional post processing to improve it further. This involves a mix of simple string manipulation, regex find and replace, and even using the TypeScript compiler to rewrite part of the file. This all happens as part of the new build-ts gulp task. If you’re curious, you can check out the code. The end result is a single Cesium.d.ts that lives alongside the generated Cesium.js module entry point.
In addition to generating the output, the build-ts task also validates the file by compiling it with TypeScript. If a developer makes a mistake in the JSDoc, such as misspelling a class name or referencing a private or non-existent type, the build process will fail. While incredibly useful, this verification process only catches certain kinds of errors. For example, if someone implements a new ImageryProvider but fails to conform to the correct interface, the definition file will compile without error but TypeScript will issue a compile error in apps that try to use the new class as an ImageryProvider.
We’re still exploring ideas for adding additional validation, such as writing some unit tests in TypeScript, to identify potential problem areas during development.
About those JSDoc errors
I’ve mentioned several times already that a particularly exciting thing about the JSDoc-based approach is that it adds another level of validation and verification to our documentation that benefits everyone, not just TypeScript developers. A good portion of our doc review process has now been automated. The issues we found in our codebase can be broken into the following categories:
- Incorrect or incomplete types In many cases, we were using the informal or incorrect name for a type, for example Image is actually HTMLImageElement and Canvas is HTMLCanvasElement. A fun one is TypedArray, which doesn’t even exist at the spec level and is instead a generic term for the full list of types, i.e. Int8Array, Float32Array, etc… We also had incomplete generics, for example Promise instead of Promise<boolean>.
- @exports - We were using JSDoc’s @exports tag as our ultimate crutch. If a developer had trouble getting something to show up in our generated HTML, chances were that they could add @exports and it would “just work.” We were using @exports for enums, namespaces, and functions instead of the @enum, @namespace, and @function tags. This led to incorrect type generation. It turns out we don’t need to use @exports anywhere in our code at all.
- Leaking private types - There were quite a few private types being referenced in the public API. These private types didn’t exist in the HTML output and were just silent failures for our documentation build step. In most cases, it made sense to simply expose the private type. Thankfully, we have a habit of documenting private types in CesiumJS as well, so no new JSDoc had to be written.
- Copy and paste errors - The last kind of JSDoc error was copy and paste–related duplicate parameter entries, e.g., having ImageryProvider, A, claim it was documenting a property on ImageryProvider, B, etc…
Even the most diligent developers miss things, so it was easy for the above problems to manifest over time when we had to rely on manual review. To be honest, I’m proud of just how good our documentation was before this effort, but now that we are being double-checked by the TypeScript compiler, it’s even better and will continue to be maintained at this new higher level.
Our hero’s journey had some extra details and hiccups along the way that I left out to avoid making this post overly long, but if you’re the type that really loves to dig into the details, feel free to check out the original pull request, #8878, on GitHub or post questions to the community forum.
What’s next?
Once the community starts using these definitions, we expect minor issues to crop up that we will address over the next few CesiumJS releases. We have also started to develop a list of ideas we would like to explore, such as leveraging generics for the Property interface used by the Entity API. Ultimately, we are relying on the community to tell us what’s most important to them so we can shape our CesiumJS with TypeScript roadmap.
We also want to figure out a way to use the TypeScript definitions internally in the CesiumJS codebase. We believe VS Code has some mechanisms to make this possible, but we haven’t explored them yet. If this proves feasible, it would be a major win and allow another level of validation not normally possible through vanilla JavaScript, not to mention making developing CesiumJS an even nicer experience than it already is.
I’m sure a lot of ears perked up when I said we evaluated rewriting CesiumJS in TypeScript. I’m definitely a proponent of getting there longer tem. As part of the evaluation process I actually built the existing JavaScript codebase with the TypeScript compiler and even ported some basic files, such as Cartesian3.js to TypeScript to understand how we can do mixed TS/JS development rather than an “all-at-once” migration strategy. Much like ES6, porting the code is the easy part. Expect a GitHub issue sometime soon that starts to break down everything that has to happen to make a TypeScript version of CesiumJS a reality; but no promises yet.
Thank you
I just wanted to say thank you again to the community for helping generate ideas and discussions around TypeScript over the last few years and I’ll give a special shoutout and thank you to @thw0rted, who was the first external contributor to improve the initial TypeScript type definitions, and who also provided a lot of good feedback in the initial pull request. Finally, a big thank you to my partner in crime and fellow maintainer, Kevin Ring, who not only provided a ton of expert knowledge and feedback, but also allowed himself to get sucked into this effort and ultimately submitted a bunch of improvements to the code.