[Node.js SDK update] Typescriptifying JavaScript code

Typescriptifying JavaScript code

12 min read
Tags:

One of the most satisfying things a developer can do is to take on a task that can reduce user error and improve maintainability, readability, and user experience all in one go. I was lucky enough to get a chance to experience this by undertaking the task of TypeScriptifying our widely used Nylas Node SDK.


This was especially enjoyable for me since I have lots of bones to pick with JavaScript, mainly revolving around how “flexible” it is and its lack of enforcement compared to a lot of other languages. While TypeScript is of course not perfect, it helps solve a lot of preventable issues by adding context to the JavaScript code and forcing the developer to be precise and explicit. 


In this post, I’ll share the important things that I learned and used during my journey through the process of TypeScriptifying our Node.js SDK.

Typing Properties & Return Types

One of the biggest advantages of working with TypeScript is its strongly typed nature. Being strongly typed means the compiler will ensure that variables are not being assigned incompatible types of values. 


To leverage this feature of TypeScript, you can statically type properties in your existing JavaScript code base. Statically typing properties means specifying a type for each property. Doing so it will help with increasing readability in the code. Furthermore, it allows you to reduce the chance for runtime errors, as an error would be thrown during compilation if a user passes in an incompatible type. 


Properties aren’t the only thing that you can specify a type for. Another recommended use of typing is for functions. You can specify the return types for functions or methods, effectively declaring what the function must return once it’s complete. Like typing properties, having return types also increases readability in the code and gives the user a better understanding of what a function does and produces. It allows the user working with your app or library to better design their application knowing that the function will produce a specific type, reducing the chance for a runtime error and improving the overall developer experience.

getArea(length: number, width: number): number {
  return length * width;
  // If we returned a string instead, our application wouldn't compile. It would throw a TS2322 compilation error, as a number is not assignable to string type
}

const area: number = getArea("5", "5"); // Throws a TS2345 compilation error, expects numbers not strings
const area: string = getArea(5, 5); // Throws a TS2322 compilation error, number is not assignable to string
const area: number = getArea(5, 5); // Valid


As you see, there is strength in being clear and explicit when writing your application. Without a specified types, getArea(“5”, “5”) would have still run and returned a number just fine, even though it’s clear the function expected numbers and not strings. But you can see where troubles start to arise if someone passed in non-numerical characters or other types of objects. 


TypeScript eliminates this ambiguity: if the user tries to invoke your function with the incorrect type they would get an error during compilation instead of getting a potentially fatal error during runtime. Also, the user can look at the method and know what is expected of them, and what to expect to get in return, eliminating confusion.

Interfaces and type aliases

Typing doesn’t have to stop properties and functions. What if you’re working with JavaScript objects where you know the structure and types within the object? 


That’s where interfaces and type aliases come in. You can leverage these to define a specific structure of an object so that you can ensure the variables that will be used are of the correct type. The users benefit as well, since they can once again better design their application by knowing what properties the object will contain as well as the types of each property. 


Interfaces and type aliases are very similar, and the Official TypeScript Handbook does a good job at summarizing the differences between them.

class Square {
  // Length and width are required and not initialized with a value, so it must be set in the constructor
  length: number;
  width: number;

  // The colour is also required, but is set to 'red' by default, and thus it is not required to be set in the constructor
  colour = 'red';

  // The name of the square is optional, and thus it does not need an initial value nor is it required to be set in the constructor. Currently, it's undefined.
  name?: string;

  constructor(length: number, width: number) {
    this.length = length;
    this.width = width;
  }
}

new Square(); // Throws a TS2554 compilation error
new Square(5); // Also throws TS2554 compilation error
new Square(5, 5); // Valid

Interfaces with typed properties are especially handy when working with APIs. If we have a function that takes parameters from the user, builds a request, and sends it to the API for processing there’s a chance that sending an incompatible type can throw a server-side error. Building and sending a malformed request only to then receive, parse, and return the error from the API causes more overhead and a dampened user experience when compared to erroring on a client-side function. 


That’s where TypeScript comes in. We can try to prevent these types of errors by having strict typing on properties that can eliminate the worry of sending incompatible types. Furthermore, if we type the properties we expect in the response, the implementing user can be better prepared for handling the inbound deserialized data. If the response returned from the API is a custom one, you can use interfaces or type aliases to define it!

Required vs. Optional Properties

Not all properties are required to instantiate a class or invoke a function. So following types, there is another powerful advantage to using TypeScript: declaring properties as optional. 


In TypeScript all properties are deemed to be required by default, unless declared otherwise using the optional (?) modifier. In classes, a required property means it must either have a default value set during initialization or outrightly passed to the constructor. 


This has benefits for both the developer and the user of the TypeScript application. Enforcing required properties ensures that the developer is evaluating the optionality of each property. If the property is deemed required then the user can be confident that when the object is instantiated it will have that property set either with a default value or with parameters in the constructor. 


In contrast, if the property is deemed to be optional by the developer, then the property does not need to be initialized with a default value nor set in the constructor. 

class Square {
  // Length and width are required and not initialized with a value, so it must be set in the constructor
  length: number;
  width: number;

  // The colour is also required, but is set to 'red' by default, and thus it is not required to be set in the constructor
  colour = 'red';

  // The name of the square is optional, and thus it does not need an initial value nor is it required to be set in the constructor. Currently, it's undefined.
  name?: string;

  constructor(length: number, width: number) {
    this.length = length;
    this.width = width;
  }
}

new Square(); // Throws a TS2554 compilation error
new Square(5); // Also throws TS2554 compilation error
new Square(5, 5); // Valid

As a result, the user knows that the value of the name property may be undefined, allowing the user to plan accordingly.

Required vs. Optional Function Parameters

Likewise, optionality in function parameters is also very useful. For developers, it helps with ensuring that the users invoke the function with properties critical to the operation of the function. Ensuring the user passes in the required parameters helps prevent runtime errors.


But not every function requires every parameter to be sent. TypeScript understands the need for this flexibility by offering two other paths: default values and optional modifiers. 


If a function requires a parameter as part of its logic but is most likely always going to be set to a specific value, then setting a default value might make a lot of sense. It ensures that the function can still operate without needing the user to bother with setting what may be a redundant value.

downloadFile(url: string, notifyWhenComplete=true): Promise<File> {
  return fetch.download(url: url).then(file => {
    if(notifyWhenComplete) {
      sendNotification("File has been downloaded");
    }

    return file;
  })
}

downloadFile(); // Throws a TS2554 compilation error
downloadFile("https://url.com/file.txt"); // Is valid and will notify when download completes
downloadFile("https://url.com/file.txt", false); // Is also valid and will not notify

For other functions, there may be parameters that are not needed in the core process of the function but that still provide extra functionality or configuration opportunities. That’s where optional parameters come in. Like optional properties, optional parameters allow the developer to be explicit in what parameters are critical to the operation of the function and what parameters are not. 


For an example of how optional parameters can bring flexibility to an application, we can look at an example where a function makes an API call to fetch data. While an API call might have required value(s) for an operation, it also may hold the capability to receive extra parameters via query strings or the JSON payload, for example. 

getUserProfile(userId: string, params?: Record<string, unknown>): Promise<UserProfile> {
  const request = new RequestBuilder({
    url: `https://api.url.com/users/${userId}`,
    params: params,
  });

  return fetch.get(request);
}

getUserProfile("abc123"); // Valid, no params are sent
getUserProfile("abc123", {
  profilePicture: true,
  revealName: false,
}); // Also valid, params will be passed along to the API

The user may want to take advantage of the API’s flexibility or they may not; either way, the developer can leave it up to the user by making the parameter optional.

Other Best Practices

Don’t use any, use unknown instead

Sometimes there are cases where the value of a variable or a function could be anything. Take a JavaScript object that represents a user-defined mapping, for example. You may not want to impose any rules on the value types in each key-value pair as long as the key is a string. How do you implement this? 


That’s where unknown comes in. It’s a type-safe type that can be used to define a variable assignable to any type. With unknown, unless you explicitly cast the variable as another type (using the as keyword), you cannot access any properties of the variable, invoke it as a function, or construct it.

const foo: unknown = 5;

const number: number = foo; // Throws TS2322, can't outright assign an unknown type to a number
const number: number = foo as number; // Valid

const exponential = foo.toExponential(); // Throws TS2571, type is unknown
const exponential = (foo as number).toExponential(); // Valid

const object = new foo(); // Throws TS2571, type is unknown

Compare the above to the following anti-pattern of using any, which exposes your user to potential runtime errors:

const foo: any = 5;

const number: number = foo; // Valid
const exponential = foo.toExponential(); // Also Valid

// This might seem super convenient until we don't use happy paths

foo = 'string'
foo.toExponential() // Valid during compile time, will throw a runtime error though
new foo() // Valid during compile time, will throw a runtime error though

Bottom line: Given its inherent type-safety, unknown is a much better alternative than using any, which offers no protections. Using any effectively makes the property act as if it was a normal JavaScript property, bypassing all the compiler’s strict typing enforcement, thus defeating the purpose of using TypeScript.


Record and other utility types

One of the coolest features of TypeScript types is having the tools to make so many different types in different ways. It brings great flexibility in a way that is easily readable.


Say you have a type and you want to make a variation that excludes a property? You can use Omit:

type FileMetadata = {
  name: string;
  size: number;
  extension: string;
  path: string;
}

type DirectoryMetadata = Omit<FileMetadata, "extension" | "size">

const fileMetadata: FileMetadata = {
  name: "file",
  size: "20",
  extension: "txt",
  path: "/path/to/file.txt",
}

const directoryMetadata: DirectoryMetadata = {
  name: "directory",
  path: "/path/to/directory",
}

Or do you have a type with no optional parameters but sometimes you don’t need all of the properties to be required? That’s what Partial is for:

type FileMetadata = {
  name: string;
  size: number;
  extension: string;
  path: string;
}

createFile(metadata: FileMetadata) {
  ...
}

queryFiles(query: Partial<FileMetadata>) {
  ...
}

const file = createFile({
  name: "file",
  size: "20",
  extension: "txt",
  path: "/path/to/file.txt",
});

const queriedFiles = queryFiles({
  name: "file",
  extension: "txt",
});

There are many, many more cool utility types that you can use. You can find them in the Official TypeScript Handbook


Another I want to specifically highlight is Record.


Record<K, V> is a utility type that represents a JavaScript object where the type of the keys are of K type and the type of the values are of V type. 

getUserProfile(userId: string, params?: Record<string, unknown>): Promise<UserProfile> {
  const request = new RequestBuilder({
    url: `https://api.url.com/users/${userId}`,
    params: params,
  });

  return fetch.get(request).then((response: Record<string, unknown>) => {
    return new UserProfile().fromJSON(response);
  });
}

setUserMetadata(userId: string, metadata: Record<string, string>): Promise<void> {
  return fetch.post({
    url = `https://api.url.com/users/${userId}`,
    body = metadata,
  });
}

const userProfile = getUserProfile("abc123", {
  profilePicture: true,
  revealName: false,
});

setUserMetadata("abc123", {
  nickname: "Joe",
});


Record<K, V> is a cleaner, more TypeScript-y way of defining the type an object compared to the standard {} syntax. Combining it with unknown it’s especially helpful when dealing with API responses before deserializing them. Or for when you want to allow the user flexibility in setting the value type in the set.

Check out the source code

The Nylas Node.js SDK is open source. To see how we TypeScriptified the SDK, head over to our GitHub repo.
We welcome contributions in the form of issues and pull requests. Check out our “Contributing” doc to learn how you can get involved.


If you want to give the Nylas Node.js SDK a spin, see our docs for how to get started.

Related resources

How to Send Emails Using an API

Key Takeaways This post will provide a complete walkthrough for integrating an email API focused…

How to build a CRM in 3 sprints with Nylas

What is a CRM? CRM stands for Customer Relationship Management, and it’s basically a way…

How to create an appointment scheduler in your React app

Learn how to create an appointment scheduler in your React app using Nylas Scheduler. Streamline bookings and UX with step-by-step guidance.