JavaScript to TypeScript: CKEditor is a TypeScript rich text editor
Ever wondered about the benefits of rewriting a rich text editor like CKEditor 5, from JavaScript to TypeScript? You probably have, since TypeScript offers a more enjoyable programming environment. However, the level of effort required for such a transition is pretty intimidating, so it might have held you back. Let’s see what we overcame during CKEditor’s journey to become a TypeScript rich text editor.
The primary reason for undertaking such a migration, is to enhance the Developer Experience (DX) of the rich text editor. But there are many others as well.
Given TypeScript’s soaring popularity, particularly within the realm of large-scale projects, it’s only natural to want to ease the process of integrating a JavaScript app into a TypeScript setup. The robust development of external typings within the community and the growing popularity of “Typings for TypeScript” strongly suggest that the effort is worthwhile.
Plus, migrating to TypeScript provides an opportunity to modernize your tech stack. After investing so much time and energy into maintaining your project, why limit yourself to JavaScript?
Also, consider the benefits of improving your own DX. Working with TypeScript can streamline your project development process, simplify issue detection and increase your developer velocity.
However, it’s crucial to tread carefully so as not to introduce breaking changes (unless absolutely necessary). Your goal should be to ensure your existing JavaScript integrations continue to operate with minimal adjustments.
Let’s delve into the nuances of a JS to TS migration, by using the CKEditor TypeScript migration as the example, to see the challenges you may encounter.
How different is TypeScript from JavaScript?
TypeScript and JavaScript may share a common heritage, but TypeScript offers a few distinct enhancements. These include static typing and a more structured object-oriented programming approach, both of which serve to make TypeScript code less susceptible to runtime errors and bugs. This often leads to more reliable applications.
In addition, TypeScript offers a superior development experience through better tooling and autocompletion features. These aspects become particularly advantageous in a large and complex project such as CKEditor.
Initiating the TypeScript transition: Where to begin?
As you embark on your TypeScript transition, it’s wise to kickstart the process with a package that sits at the base of your dependency tree. Such a package likely has fewer dependencies, making the initial phase simpler and setting a solid foundation for later stages by utilizing the types you’ve already converted.
Below is a high-level overview of the steps involved:
-
Add a
tsconfig.json
file:
This is crucial for configuring the TypeScript compiler options tailored to your project’s needs. -
Update the
**webpack.config.js**
file:
This adjustment instructs Webpack to accommodate.ts
files during the build process. -
Rename files and add types:
This involves meticulously traversing each file in the package, changing their extensions from.js
to.ts
, and adding types where required.
After the successful migration of the first package, you’ll follow the same methodology for all other packages within your project.
Activating strict Null Checks: Dodging the billion-dollar mistake
Would you consider enabling strict
or strictNullChecks
to true
in your tsconfig.json
? It’s indeed a wise choice.
This stringent mode allows a sharper distinction between instances when your code deliberately uses undefined
or null
values for a specific field or variable, and when these values have merely not been initialized along certain paths. It serves as an indispensable tool that aids in avoiding the infamous “billion-dollar mistake” of null references.
But what does it mean for a mammoth project like CKEditor 5, with close to 80,000 lines of code, excluding tests and tooling?
When your TypeScript compiler flags a value as potentially undefined
or null
, you’re presented with two options. You can employ the non-null assertion operator (!
), effectively assuring the compiler that the value will not be null
or undefined
. Alternatively, you might decide to implement a null-value guard in your code, utilizing an if
statement or a conditional operator. This explicit handling indeed renders your code safer, but it doesn’t come without consequences.
Incorporating a guard introduces a new branch in your code. While this might seem like a minor modification, it can impact your test coverage, potentially leading to a decline.
A drop in test coverage calls for additional tests to uphold code reliability. However, the logic leading to a possibly null value could be external to the function, making it impossible to generate a null value during testing. Hence, adding guards can not only affect performance due to the extra checks but also amplify the complexity of your tests, demanding more time and effort.
Your challenge lies in striking a balance between:
- Conducting thorough refactoring to achieve safer code
- While preserving the original code through non-null assertions or casts.
Tackling event typing in CKEditor 5: The challenges
CKEditor 5 is heavily reliant on events, akin to DOM events, through its use of EmitterMixin. Have you ever longed for your compiler to infer types for callback parameters? Imagine the convenience of having parameter types disclosed right within your event callbacks:
obj.on( 'myEvent', ( param1, param2 ) => { /* Wouldn't it be great to have param types here? */ } );
Thankfully, TypeScript offers an elegant pattern that enables us to make this a reality:
interface EventMap {
myEvent: [number, string];
anotherEvent: [boolean, string, number];
// Add more events as needed...
}
// In CKEditor 5 it isn't a class, but let's simplify for now.
class Emitter {
on<Event extends keyof EventMap>( eventName: Event, callback: ( ...args: EventMap[ Event ] ) => void ) {
// ...
}
}
However, the event environment in CKEditor 5 isn’t always as straightforward, and you’ll stumble upon a few edge cases:
Edge Case 1: Different classes emit diverse events. Thus, you need to design your Emitter
class to be flexible, accommodating the dynamic EventMap
.
class Emitter<EventMap> {
on<Event extends keyof EventMap>( eventName: Event, callback: ( ...args: EventMap[ Event ] ) => void ) {
// ...
}
}
class MyClass extends Emitter<MyClassEventMap> { /* ... */ }
Edge Case 2: Observers in CKEditor 5 possess the capability to instigate events on other classes. You can extend their event map in another file. We’ll delve into this topic in the upcoming text.
Edge Case 3: Event delegation. In CKEditor 5, there’s a feature that allows one class to delegate its events to another class. Despite TypeScript providing a mechanism for inferring event types, this feature becomes restricted in the context of event delegation due to its dynamic nature.
Edge Case 4: Emitter does more than just emitting events; it also listens to them. This functionality is useful for detaching from all events at once (with the stopListening function) when you’re destroying your class. Here’s a real use case:
class Emitter<EventMap> {
listenTo<Event extends keyof EventMap2, EventMap2>( obj: Emitter<EventMap2>, eventName: Event, callback: ( ...args: EventMap2[ Event ] ) => void ) {
// ...
}
}
class MyClass extends Emitter<MyClassEvents> { /* ... */ }
// Assume obj2 is an instance of MyClass.
obj1.listenTo( obj2, 'myEvent', ( param1, param2 ) => { /* ... */ } );
// But will the types be inferred here?
obj1.listenTo<'myEvent', MyClassEvents>( /* ... */ );
Unexpectedly, TypeScript doesn’t cooperate as you might expect. If you pass MyClass
to listenTo
, it seems logical that the compiler would infer EventMap2
as MyClassEvents
. However, due to TypeScript’s structural typing, the compiler ignores the provided type since all Emitters
share the same structure. What you get is:
obj1.listenTo<never, unknown>( /* ... */ );
The question then is: what can you do about it? You could provide generic parameters explicitly at every call site, or, for a more user-friendly approach, provide only one generic parameter:
interface BaseEvent {
name: string;
args: Array<any>;
}
class Emitter {
listenTo<Event extends BaseEvent>( obj: Emitter, eventName: Event[ 'name' ], callback: ( ...args: Event[ 'args' ] ) => void ) {
// ...
}
}
interface MyEvent {
name: 'myEvent';
args: [ string, number ];
}
obj1.listenTo<MyEvent>( obj2, 'myEvent', ( param1, param2 ) => { /* ... */ } );
Module augmentation: Enhancing CKEditor 5 configuration with TypeScript
CKEditor 5 is widely recognized for its extensive configurability. When using it in a TypeScript context, you may want to associate configurations with their corresponding types to leverage the benefits of the compiler in accurately setting up editor instances.
// Assigning a type to the configuration.
export interface EditorConfig { /* ... */ }
let myEditor = ClassicEditor.create( {
// The compiler will type-check whatever you place here.
} );
However, CKEditor 5’s configurability is not it’s only strength. It’s also highly modular, serving as a framework at its core, with plugins transforming it into the feature-rich editor you’re familiar with.
Considering the multitude of plugins available – some bundled and essential, others optional or community-created – attempting to compile all possible configuration values into a single EditorConfig interface becomes impractical. This is where TypeScript’s module augmentation comes into play.
declare module '@ckeditor/ckeditor5-core' {
interface EditorConfig {
myConfig: string;
}
}
Module augmentation is a powerful feature that allows distributed interface definitions. The code snippet above does not dynamically extend the configuration at runtime. Instead, it enables the compiler to merge separate definitions into a unified entity. However, it requires careful usage.
You need to ensure that your configuration extension is imported (though not necessarily directly) wherever you intend to use it.
Don’t limit yourself to configuring the editor alone. Module augmentation can be used to type-check various other elements. For example:
editor.execute('myCommand', { /* Type-check these parameters */ } );
// Add an interface without using it for any value.
export interface CommandsMap {
// Fallback type.
[name: string]: Command;
}
export class Editor {
execute<Name extends string>( name: Name, ...params: Parameters<CommandsMap[ Name ][ 'execute' ]> ): void {
// ...
}
}
// Inside your plugin.
declare module '@ckeditor/ckeditor5-core' {
interface CommandsMap {
myCommand: MyCommand;
}
}
API documentation: Farewell JSDoc, hello TypeDoc
TypeDoc is a powerful documentation generator specifically designed for TypeScript projects. It utilizes TypeScript’s own compiler to extract detailed type information from your code, ensuring that your documentation remains synchronized with your codebase.
By harnessing the benefits of static typing, TypeDoc can provide comprehensive and accurate documentation that accurately reflects the current state of your code.
Since TypeDoc can access the same type information that the TypeScript compiler uses, you can safely remove redundant type annotations from your documentation comments.
For example, instead of using JSDoc like this:
/**
* @param {String} name - The name of the user.
*/
function sayHello(name) { /*...*/ }
You can simplify it in TypeDoc as follows:
/**
* @param name - The name of the user.
*/
function sayHello( name: string ) { console.log( `Hello, ${ name }!` ); }
However, transitioning to TypeDoc involves more than just removing type information from comments. It also requires you to adapt your documentation approach to incorporate TypeScript-specific concepts, such as interfaces, auxiliary types, and function overloads.
The Power of TypeScript’s Static Types: How it makes a difference
1. Improved code quality
The introduction of static typing in TypeScript immediately elevates the overall quality of your code. TypeScript enforces stricter syntax rules, resulting in fewer errors and less cluttered code.
As a result, you have a more robust and cleaner codebase – making it easier to maintain and further develop.
2. Enhanced developer experience
Static types in TypeScript greatly improve code readability, particularly when working with complex and nested structures. The explicit type definitions provide guidance to your integrated development environment (IDE), enabling more accurate autocompletion.
This accelerates your coding process and reduces the likelihood of errors. Plus, the increased clarity of your code makes your API more discoverable and user-friendly.
3. Better debugging
The presence of static types in TypeScript helps detect bugs during the compilation phase rather than at runtime. By checking types during compilation, TypeScript can prevent many common mistakes that may otherwise lead to bugs in a JavaScript codebase.
This results in fewer unexpected errors and saves valuable time that otherwise would have been spent on debugging.
4. Improved API documentation
The migration to TypeScript can significantly enhance your API documentation. With static types, you can provide more precise and accurate information about the functions and data structures used in your API. This makes it easier for others to understand and utilize your software.
The improved documentation empowers developers to work more effectively with your API and reduces the learning curve associated with integrating CKEditor 5 into their projects.
The rewards of the CKEditor TypeScript migration
The journey to TypeScript may present some challenges along the way, but the rewards make it worthwhile. While it can be daunting, especially when dealing with complex features like event types and module augmentation, TypeScript provides powerful tools that enable you to create a more reliable and user-friendly framework.