In this task, we’ll refactor the existing JavaScript code to TypeScript. TypeScript is a superset of JavaScript that adds static type checking and other features to help you write more robust and maintainable code.
Declaring Types for the API Response
We can start by declaring types for the API response data. Currently, the API response is an array of objects with the following structure:
The above is the example response provided on the Free Dictionary API homepage. We can create type definitions for the API response data.
Declaring a Type for the Definition Object
Let’s start by declaring a type for the Definition object:
The Definition type represents the structure of the Definition object in the API response. It has four properties:
definition: A string representing the definition of the word.
example: An string representing an example sentence using the word. In our exploration of the API response, we found that this property may be missing in some cases. Therefore, we’ve marked it as optional using the ? operator.
synonyms: An array of strings representing synonyms of the word. Similar to the example property, this property may be missing in some cases, so we’ve marked it as optional.
antonyms: An array of strings representing antonyms of the word. This property is also optional since it may be missing in some cases.
Notice we use the keyword type to define a new type in TypeScript. The general syntax for defining a type is type TypeName = { /* type definition */ };. Moreover, we define the properties of the type using the colon (:) syntax, where we specify the property name followed by the property type. For example, definition: string specifies that the definition property is of type string.
In TypeScript, the following basic types cover most of the use cases:
string: Represents a string value.
number: Represents a numeric value.
boolean: Represents a boolean value (true or false).
object: Represents a JavaScript object.
To declare an array type, we use the syntax Type[], where Type is the type of the elements in the array. For example, string[] represents an array of strings.
We often use these basic types to annotate the data type of variables, function parameters, and return values in TypeScript. For example, consider the following variable declaration:
In TypeScript, we can annotate the type of the dictionaryAPI variable as follows:
Notice how the type annotation : string is added after the variable name to specify that the dictionaryAPI variable is of type string. This is followed by the assignment operator (=) and the value of the variable.
The type annotations are optional in TypeScript, as TypeScript can infer the types from the assigned values. However, adding type annotations can help catch errors early and improve code readability.
The real power of TypeScript comes from defining custom types and interfaces to represent complex data structures. Let’s continue defining types for the API response data.
Declaring a Type for the Meaning Object
Next, let’s declare a type for the Meaning object:
The Meaning type represents the structure of the Meaning object in the API response. It has two properties:
partOfSpeech: A string representing the part of speech of the word.
definitions: An array of Definition objects representing the definitions of the word.
Notice that the definitions property is an array of Definition objects. This is an example of how we can compose types in TypeScript. We can use one type as a property of another type, creating a more complex structure.
Declaring a Type for the Phonetic Object
Next, let’s declare a type for the Phonetic object:
The Phonetic type represents the structure of the Phonetic object in the API response. It has two properties:
text: A string representing the phonetic text. We found that this property may be missing in some cases, so we’ve marked it as optional.
audio: A string representing the URL to the audio file for the phonetic text. This property is also optional since it may be missing in some cases.
Declaring a Type for the DictionaryAPIResponse Object
Finally, let’s declare a type for the DictionaryAPIResponse object, which represents the structure of the API response data:
The DictionaryAPIResponse type represents the structure of the API response data. It has five properties:
word: A string representing the word which the response data is about.
phonetic: A string representing the phonetic pronunciation of the word. This property is marked optional since it may be missing in some cases.
phonetics: An array of Phonetic objects representing the phonetic information of the word.
origin: A string representing the origin of the word. This property is optional.
meanings: An array of Meaning objects representing the meanings of the word.
With these type definitions in place, we can now use them to annotate the API response data in our code.
Using Interfaces for Type Declarations
In TypeScript, we can also use interfaces to define object types. Interfaces are similar to type aliases but have some differences in how they can be extended and implemented. For most use cases, type aliases and interfaces are interchangeable, so you can choose the one that fits your coding style.
I prefer using interfaces for defining object types because I can use the extends keyword to create subtypes and extend existing types. This is a familiar concept if you’ve worked with object-oriented programming languages. On the other hand, type aliases are more like sets of types that can be combined using union (|) or intersection (&) operators.
Let’s rewrite the type definitions we created earlier using interfaces:
Notice the syntax for defining interfaces: interface InterfaceName { /* interface definition */ };. The properties of the interface are defined in the same way as type aliases, using the colon (:) syntax to specify the property name followed by the property type.
As far as the syntaxt goes, the difference between type aliases and interfaces is minimal:
For type aliases, we use the type keyword, while for interfaces, we use the interface keyword.
We use = to assign a type to a type alias and { /* type definition */ } to define the type structure. For interfaces, we use { /* interface definition */ } to define the interface structure. (The = sign is not used with interfaces.)
In the next steps, we’ll every function parameter and return value with the appropriate type annotations.
Refactor searchWord Function
Here is the original searchWord function:
We can refactor this function to include type annotations for the word parameter and the return value. Here’s the refactored version using type annotations:
Let’s break down the changes:
We added a type annotation for the word parameter: word: string. This specifies that the word parameter should be a string.
We added type annotation for the data variable: const data: DictionaryAPIResponse[]. This specifies that the data variable should be an array of DictionaryAPIResponse objects.
We added a return type annotation for the function: Promise<DictionaryAPIResponse[] | undefined>. This specifies that the function returns a promise that resolves to an array of DictionaryAPIResponse objects or undefined.
Let’s focus on the syntax of the return type annotation: Promise<DictionaryAPIResponse[] | undefined>. This indicates that the searchWord function returns a promise. The angle brackets (< >) contain the type of the resolved value of the promise. If you have worked with generics in other languages, like Java, this syntax might look familiar. In this case, they type of the resolved value is DictionaryAPIResponse[] | undefined. The pipe (|) operator is used to specify that the resolved value can be an array of DictionaryAPIResponse objects or undefined.
The undefined type is used to represent a value that may be undefined. In TypeScript, undefined is a valid value that can be assigned to variables. By including undefined in the return type, we indicate that the function may return undefined if an error occurs during the API call. The catch block in the function handles the error case, where we log an error message to the console. The missing return statement in the catch block implicitly returns undefined.
You might be wondering why we use Promise<DictionaryAPIResponse[] | undefined> instead of just DictionaryAPIResponse[] | undefined. The reason is that the searchWord function is asynchronous. Therefore, the return type of the searchWord function should reflect this asynchronous nature by using Promise.
Aside: The fetch throws error due to network issues, not due to the response status code. So, if the network request fails, the catch block will be executed. However, if the request is unsuccessful due to any issues, the API server will still return a response with a status code of 4xx or 5xx. In such cases, the response.ok property will be false. We can check this property to handle errors due to response status codes.
In this updated version, we added a check for the response.ok property to handle errors due to response status codes. If the response.ok property is false, we throw an error with the HTTP status code. This allows us to handle failed requests similar to network errors.
Refactor extractWordDefinitions Function
Here is the original extractWordDefinitions function:
We can refactor this function to include type annotations for the data parameter and the return value. Here’s the refactored version using type annotations:
Let’s break down the changes:
We added a type annotation for the data parameter: data: DictionaryAPIResponse[] | undefined. This specifies that the data parameter should be an array of DictionaryAPIResponse objects or undefined.
We added a return type annotation for the function: Meaning[] | undefined. This specifies that the function returns an array of Meaning objects or undefined.
We used optional chaining (?.) and the nullish coalescing operator (??) to safely access nested properties and handle cases where the data structure may be incomplete or missing.
Optional Chaining (?.)
The optional chaining operator (?.) allows you to safely access nested properties of an object without causing an error if a property is null or undefined. If the property you are trying to access is null or undefined, the expression short-circuits and returns undefined. This can help prevent TypeError errors when working with nested data structures.
Nullish Coalescing Operator (??)
The nullish coalescing operator (??) provides a way to provide a default value when a value is null or undefined. If the value on the left side of the operator is null or undefined, the operator returns the value on the right side. Otherwise, it returns the value on the left side. This can be useful for providing fallback values when dealing with potentially null or undefined values.
The ?? is a shorter way of writing the following conditional expression:
By using the nullish coalescing operator, we can write more concise and readable code when handling default values.
The combination of optional chaining and the nullish coalescing operator allowed us to refactor the following code into a single line:
Refactor extractWordPhonetics Function
Here is the original extractWordPhonetics function:
We can refactor this function to include type annotations for the data parameter and the return value. Here’s the refactored version using type annotations:
Similar to the extractWordDefinitions function, we used optional chaining and the nullish coalescing operator to safely access nested properties and handle cases where the data structure may be incomplete or missing.
Refactor clearDefinitionsSection Function
Here is the original clearDefinitionsSection function:
We can refactor this function to include type annotations for the return value. Here’s the refactored version using type annotations:
We added a return type annotation for the function: HTMLElement. This specifies that the function returns an HTMLElement object. Notice that we used a type assertion (as HTMLElement) to cast the return value of document.getElementById("definitions") to an HTMLElement. This is necessary because the getElementById method returns an Element | null type, and we need to specify that the return value is an HTMLElement.
Type Assertion in TypeScript
Type assertion in TypeScript is a way to tell the compiler about the type of a variable when the compiler cannot infer it automatically. It is like type casting in other languages, but it does not change the type of the variable at runtime. Instead, it is used by the TypeScript compiler to understand the type of a variable during static analysis.
Refactor createDefinitionsHeading Function
Here is the original createDefinitionsHeading function:
We can refactor this function to include type annotations for the return value. Here’s the refactored version using type annotations:
Refactor createDefinitionDiv Function
Here is the original createDefinitionDiv function:
We can refactor this function to include type annotations for the return value. Here’s the refactored version using type annotations:
Refactor createPartOfSpeechElement Function
Here is the original createPartOfSpeechElement function:
We can refactor this function to include type annotations for the partOfSpeech parameter and the return value. Here’s the refactored version using type annotations:
Refactor createDefinitionsList Function
Here is the original createDefinitionsList function:
We can refactor this function to include type annotations for the return value. Here’s the refactored version using type annotations:
Refactor createDefinitionItem Function
Here is the original createDefinitionItem function:
We can refactor this function to include type annotations for the definitionObj parameter and the return value. Here’s the refactored version using type annotations:
Notice we used our custom Definition type to annotate the definitionObj parameter. This ensures that the definitionObj parameter should have the structure defined by the Definition type. For example, the definitionObj parameter should have a definition property of type string. If the definitionObj parameter does not match the Definition type, TypeScript will raise a type error. However, this only happens during static analysis, so you won’t see runtime errors due to type mismatches. So, if we make an API call and the response structure changes, TypeScript will not catch the errors when the application is running.
Runtime Behavior and Erased Types
TypeScript is a statically typed language, which means that type checking is done at compile time. Once the TypeScript code is compiled to JavaScript, the type annotations are removed, and the resulting JavaScript code does not contain any type information. This process is known as type erasure.
When the JavaScript code runs in the browser or Node.js environment, there is no type checking happening at runtime. The type annotations are purely for the benefit of the developer and the TypeScript compiler. This means that any type errors that are not caught during compilation will not be detected at runtime.
Refactor displayWordDefinition Function
Here is the original displayWordDefinition function:
We can refactor this function to include type annotations for the meanings parameter. Here’s the refactored version using type annotations:
Notice the return type annotation for the function: void. This specifies that the function does not return a value. The function is responsible for updating the DOM to display the word definitions but does not return any data.
Void Return Type in TypeScript
In TypeScript, the void type is used to indicate that a function does not return a value. It helps make the intention clear that the function is not meant to return anything, even though in JavaScript, functions that do not explicitly return a value do return undefined.
Here’s how it works:
JavaScript Behavior: In JavaScript, if a function doesn’t explicitly return a value, it implicitly returns undefined.
TypeScript void: TypeScript uses the void type to signify that a function is not supposed to return a value. It’s a way to document the intent and enforce that the function does not return any value that should be used.
When you annotate a function with void in TypeScript, it means you should not return a value from that function. If you try to return a value from a function annotated with void, TypeScript will give a compile-time error.
Refactor Functions related to Phonetics
Here are the original functions related to phonetics:
We can refactor these functions to include type annotations for the parameters and return values. Here’s the refactored version using type annotations:
Refactor the Event Listener
Here is the original event listener code:
We can refactor this event listener code to include type annotations and use the updated functions. Here’s the refactored version using type annotations:
Notice the type annotations for the inputWord and submitBtn variables. We used type assertions (as HTMLInputElement and as HTMLButtonElement) to cast the return values of document.getElementById to the appropriate types. This is necessary because the getElementById method returns an Element | null type, and we need to specify the exact type of the element.
The event listener callback function now includes a type annotation for the event parameter: event: Event. This specifies that the event parameter should be an Event object. We added a call to event.preventDefault() to prevent the default form submission behavior when the submit button is clicked. This iss not necessary because the submit button was not part of a form element. However, it’s a good practice to include this line to prevent unexpected behavior in case the button is later added to a form. Moreover, if we don’t use the event parameter in the callback function, the type-checker will raise a warning that “‘event’ is declared but its value is never read.” To prevent this warning, we can either use the event parameter as we did here or use an underscore (_) to indicate that the parameter is intentionally unused:
Refactor the Event Listener to an Async Function
We can refactor the event listener callback function to an async function to use the await keyword with the searchWord function. Here’s the updated event listener code:
By using the async keyword with the event listener callback function, we can use the await keyword with the searchWord function to wait for the API response. This makes the code more readable and easier to understand. The try-catch block allows us to handle errors from the searchWord function in a more structured way.
Refactor the searchWord Function to Throw Errors
This is the latest version of the searchWord function that we refactored earlier:
Notice in the catch block, we log the error to the console but do not re-throw the error. This means that the searchWord function will not reject the promise with the error. Instead, it will resolve the promise with undefined. This behavior is fine for the current implementation, but if you want to propagate the error to the caller, you can re-throw the error in the catch block:
By re-throwing the error in the catch block, the searchWord function will reject the promise with the error, allowing the caller to handle the error appropriately. This can be useful if you want to handle errors at a higher level in the application or provide more detailed error messages to the user.
Notice we don’t need the | undefined in the return type annotation because the function will always return a promise that resolves to an array of DictionaryAPIResponse objects or rejects with an error.
Run the Application
After refactoring the code with type annotations, you can run the application to ensure that everything works as expected. Open the terminal and navigate to the project directory. Then, run the following command to start the development server:
This command will start the development server and open the application in your default web browser. You can enter a word in the input field and click the “Submit” button to search for its definitions and phonetics. The application should display the word definitions and phonetics on the screen.
To ensure there are no type errors, run the following command:
Putting It All Together
Here is the final version of the refactored code:
Before concluding this task, I would like to show you how we can make our code more concise and readable by using template strings to create HTML elements. This approach not only simplifies our code but also makes it more familiar to JSX, which we will be using extensively in our upcoming lessons on React. By directly setting the innerHTML of elements with template strings, we can avoid the verbosity of creating and appending each element individually. Here is the refactored version of our dictionary app that demonstrates this technique:
This approach has several benefits:
Conciseness: The code is shorter and easier to read, which can make maintenance simpler.
Familiarity with JSX: Using template strings to construct HTML is similar to JSX, which will help ease the transition to React.
Clarity: It is immediately clear what the HTML structure will be, making the code more intuitive.
However, there are also some drawbacks to consider:
Security: Directly setting innerHTML can expose your application to cross-site scripting (XSS) attacks if any of the input data is not properly sanitized.
Performance: Modifying the innerHTML property forces the browser to reparse and reconstruct the DOM, which can be less efficient for large updates compared to manipulating the DOM elements directly.
Maintainability: For complex structures, inline HTML within JavaScript can become harder to manage and debug.
As we move forward into React, you’ll see how JSX provides a more robust solution that combines the benefits of template strings with additional safety and performance optimizations. React’s declarative nature and component-based architecture will help us manage complex UI logic more effectively and securely.