TypeScript Learning Adventures: A Tale of Love and Hate - Index properties, function overloads, and more

TypeScript, a powerful superset of JavaScript, brings an array of advanced features and syntax enhancements to the table. Among its many impressive capabilities, four important features

TypeScript Learning Adventures: A Tale of Love and Hate - Index properties, function overloads, and more
Photo by Michał Parzuchowski / Unsplash

TypeScript, a powerful superset of JavaScript, brings an array of advanced features and syntax enhancements to the table.

Among its many impressive capabilities, four important features are:

  • index properties,
  • function overloads,
  • optional chaining,
  • nullish coalescing operator,

Understanding and effectively utilizing these features can greatly enhance your development experience and empower you to write more robust and expressive code.

Index properties

This particular feature provides us with the ability to write adaptable code, enabling the creation of objects that can accommodate a variety of properties as needed.

To illustrate this concept further, let's consider an application that performs input validation.

In this scenario, we have multiple input fields, and depending on the user's input and the specific field they are interacting with, we may need to store and display different error messages.

For instance, if the user is filling out an email field, we want to verify the validity of the email address. If it is invalid, we would then add an appropriate error message to the designated validation rules.

With index properties, we can dynamically handle various properties and their associated behaviors, allowing for a more flexible and adaptable approach to our code:

interface ValidationRules {
  email: string;
  username: string;
}

// { email: 'Not a valid email', username: 'Must start with a character' }

I will define an interface for validation rules, which needs to be an object to accommodate various rules. This is why I chose to use an interface in this case, as it provides the flexibility required.

My ultimate goal is to handle objects that contain an error identifier, ideally associated with the input field where the error occurred.

For instance, if there is an error in the email field, the corresponding validation message should be "not a valid email." Similarly, for the username field, the error message might be "must start with a character." These error messages should be stored within the validation rules object.

However, the challenge lies in not knowing the exact property names that will exist within the validation rules object in advance.

While we may anticipate properties like "email" and "username," I want this container to be adaptable and applicable to any form on my webpage. Each form may have different input fields with their own identifiers, so I don't want to limit the validation rules to only "email" and "username" errors.

To summarize, I require an object where the value type is clearly defined as a string, but the number and names of the properties are unknown beforehand. I want a solution that provides flexibility.

For such a scenario, index types prove to be valuable:

interface ValidationRules {
  [key: string]: string;
}

To create an index type, you utilize square brackets instead of specifying a specific property name as you normally would. Within the square brackets, you can choose any name you prefer, such as "key" or "prop".

After the chosen name, you include a colon and then specify the value type for the property.

It's worth noting that you can use strings, numbers, or symbols as property types within the index type.

However, boolean values are not allowed in this context:

interface ValidationRules {
  [key: number]: string;    // ok
  [key: boolean]: string;   // doesn't work
}

By doing this, I am simply indicating that any object created based on the error container interface must have properties that are strings. For instance, a username would be considered a valid string even without quotation marks. Following the colon, the value type is specified as a string.

Essentially, what I am conveying is that I do not know the exact names or the number of properties in this object. However, I do know that every property added to this object, derived from the ValidationRules, must have a property name that can be interpreted as a string and the corresponding value must also be a string.

Additionally, we still have the option to include predefined properties, but they must adhere to the same type of requirement as outlined here.

For instance, we could include an ID property with a string type:

interface ValidationRules {
  id: string;
  [key: string]: string;
}

Following this, any object constructed in accordance with this interface must include an ID property.

It is permissible to add any number of additional properties to the object. However, it is important to note that the ID property cannot be assigned a number value in this context.

interface ValidationRules {
  id: number;  // not OK
  [key: string]: string;
}

Due to the presence of an index type in this case, setting the ID property to a number is restricted. This limitation arises when constructing objects using this approach.

Now, let's explore the benefits of adopting this methodology:

interface ValidationRules {
  [key: string]: string;
}

const rules: ValidationRules = {
  email: "not a valid email",
  username: "must start with a character"
}

We now have the ability to generate a rules object, such as ValidationRules, which is essentially an object with properties.

As an example, let's consider the "email" property, which holds the value "not a valid email." It is important to note that assigning a number to this property would be invalid since we have specified that every property must have a value type of string.

Therefore, we are required to use a string for this particular property. However, if we were to assign a number, it would still work because numbers can be interpreted as strings as well:

interface ValidationRules {
  [key: string]: string;
}

const rules: ValidationRules = {
  1: "not a valid email",
  2: "must start with a character"
}

So, I have the freedom to utilize a number as a key type in this context if I desire. There are no strict limitations on what I can use here.

This is how you exercise control over the types of properties or property names you allow. In this case, I have specified that string property names are permitted.

Therefore, any value that can be converted to a string is considered a valid property name, and the corresponding value must also be a string.

This rule set, constructed using the ValidationRules approach, grants us the flexibility to work without prior knowledge of the specific property names or the total number of properties required. It allows us to dynamically handle these aspects as needed.

Similarly, we can apply a similar approach with functions:

interface ValidationRules {
  [key: string]: (value: string) => boolean;
}

const rules: ValidationRules = {
  username: (value) => value.length >= 6,
  email: (value) => /\S+@\S+\.\S+/.test(value),
};

So this is indeed a very flexible powerful TypeScript tool for object creation.

Function overloads

This functionality enables us to establish multiple function signatures for a single function.

In essence, it means that we can have various ways of invoking the function with different sets of parameters, allowing us to perform different operations within the function based on the specific call.

Photo by eskay lim / Unsplash

Let's see this example from the previous article:

type Measurement = string | number;

function add(first: Measurement, second: Measurement) {
  if (typeof first === "string" || typeof second === "string") {
    return first.toString() + second.toString();
  }
  return first + second;
}

The previously written add function accepts two values that can be combined, which can be either strings or numbers.

As evident from the inferred return type in TypeScript, it assumes that the function will always return a value of type Measurement, which can be either a string or a number. However, upon deeper consideration, this assumption is not entirely accurate.

In reality, we know that if we provide two numbers as input, the function will always return a number. Similarly, if at least one string is passed as an argument, the function will return a string. This distinction becomes relevant in certain scenarios.

So, why does this distinction matter?

type Measurement = string | number;

function add(first: Measurement, second: Measurement) {
  if (typeof first === "string" || typeof second === "string") {
    return first.toString() + second.toString();
  }
  return first + second;
}

const result = add(3, 7);

When invoking the add function with the arguments 3 and 7, we observe that the resulting value is of type Measurement.

Consequently, TypeScript cannot determine whether the result is specifically a number or a string.

This becomes particularly significant in situations where we are working with string inputs:

type Measurement = string | number;

function add(first: Measurement, second: Measurement) {
  if (typeof first === "string" || typeof second === "string") {
    return first.toString() + second.toString();
  }
  return first + second;
}

const result = add("Mensur", " Duraković");
result.concat('add this'); // throws an error

If I provide the input values "Mensur" and "Duraković", the function still returns a value of type Measurement.

As a consequence, I am unable to invoke string-specific functions, such as .concat(), concatenating the strings.

Although I am aware that this operation would be successful because the function enters the corresponding if branch and returns a string, TypeScript only recognizes that the result can be either a string or a number.

However, we can address this situation by utilizing type casting to explicitly inform TypeScript that the returned value is a string.

type Measurement = string | number;

function add(first: Measurement, second: Measurement) {
  if (typeof first === "string" || typeof second === "string") {
    return first.toString() + second.toString();
  }
  return first + second;
}

const result = add("Mensur", " Duraković") as string;
result.concat('add this'); // now it is OK

We can achieve this by using the type casting syntax discussed in the previous article.

However, it is not ideal that we have to resort to this approach.

It seems unnecessary to write additional code when we would naturally expect TypeScript to comprehend that whenever we call this function in this specific manner, we consistently receive a string as the result. Unfortunately, TypeScript is not adequately analyzing our code in this scenario.

This is where the utilization of function overloads can assist us:

type Measurement = string | number;

function add(first: string, second: string): string
function add(first: number, second: number): number
function add(first: Measurement, second: Measurement) {
  if (typeof first === "string" || typeof second === "string") {
    return first.toString() + second.toString();
  }
  return first + second;
}

const result = add("Mensur", " Duraković");
result.concat('add this');

To define a function overload, you simply write the function declaration above the main function, using the same name.

Essentially, you repeat the function definition line without the curly braces. Instead, you specify the specific argument types (such as number and number, string and string, or other combinations) and the corresponding return type for each case.

By employing function overloads, we are instructing TypeScript that if we call this function with both arguments being numbers, the function will return a number.

It's important to note that this syntax would not be valid in JavaScript and will be eliminated during the TypeScript compilation process. However, TypeScript merges the function information and declarations together, combining the knowledge from these few lines.

As a result, TypeScript gains the understanding that the function can be called with the following combinations:

  • First and second arguments of type Measurement
  • First and second arguments of type number
  • First and second arguments of type string

Function overloads prove beneficial in scenarios where TypeScript is unable to accurately infer the return type on its own.

By using function overloads, we can explicitly define the return types for different combinations that the function supports, ensuring clarity and accuracy in our code.

Optional chaining

Consider an application in which you retrieve data from a backend, a database, or any other source where the presence of a specific property in an object is uncertain.

Photo by Miltiadis Fragkidis / Unsplash

Let's say we have the user object we fetched from the backend:

const user = {
  id: 1,
  name: "Leanne Graham",
  username: "Bret",
  email: "Sincere@april.biz",
  address: {
    street: "Kulas Light",
    suite: "Apt. 556",
    city: "Gwenborough",
    zipcode: "92998-3874",
    geo: {
      lat: "-37.3159",
      lng: "81.1496",
    },
  },
  phone: "1-770-736-8031 x56442",
  website: "hildegard.org",
  company: {
    name: "Romaguera-Crona",
    catchPhrase: "Multi-layered client-server neural-net",
    bs: "harness real-time e-markets",
  },
};

console.log(user.address.city);

In the case of fetching data from a backend, there may be instances where we don't retrieve all the required data or certain data remains unset.

In larger and more intricate applications, it is common to work with structured nested data, where the presence or absence of a specific property on an object may be uncertain or undefined.

const user = {
  id: 1,
  name: "Leanne Graham",
  username: "Bret",
  email: "Sincere@april.biz",
  phone: "1-770-736-8031 x56442",
  website: "hildegard.org",
  company: {
    name: "Romaguera-Crona",
    catchPhrase: "Multi-layered client-server neural-net",
    bs: "harness real-time e-markets",
  },
};

console.log(user.address.city);

/* 
Property 'address' does not exist on type '{ id: number; name: string; username: string; email: string; phone: string; website: string; company:{ name: string; catchPhrase: string; bs: string; }; }'
*/

In this scenario, let's assume that the address property does not exist due to various reasons, such as not fetching it or it being unset.

TypeScript raises an error in such cases because it recognizes that the address property is missing.

The issue arises because TypeScript lacks information about the data, especially if it comes from an external file that TypeScript doesn't control, or if it is fetched from a backend where the returned data is uncertain.

In regular JavaScript, when unsure about the existence of a property, we can attempt to access it and, if successful, proceed to access its sub-properties like the city:

const user = {
  id: 1,
  name: "Leanne Graham",
  username: "Bret",
  email: "Sincere@april.biz",
  phone: "1-770-736-8031 x56442",
  website: "hildegard.org",
  company: {
    name: "Romaguera-Crona",
    catchPhrase: "Multi-layered client-server neural-net",
    bs: "harness real-time e-markets",
  },
};

console.log(user.address && user.address.city);

In JavaScript, it is common to check the existence of a property before accessing nested properties to avoid runtime errors. If the property is undefined, the subsequent code is skipped to prevent errors.

However, TypeScript offers a more elegant solution to handle this scenario, using the optional chaining operator:

const user = {
  id: 1,
  name: "Leanne Graham",
  username: "Bret",
  email: "Sincere@april.biz",
  phone: "1-770-736-8031 x56442",
  website: "hildegard.org",
  company: {
    name: "Romaguera-Crona",
    catchPhrase: "Multi-layered client-server neural-net",
    bs: "harness real-time e-markets",
  },
};

console.log(user?.address.city);

By adding a question mark after the potentially undefined property, TypeScript can determine if it exists before attempting to access nested properties.

This feature is available in TypeScript versions 3.7 or higher.

Using the optional chaining operator, we can safely access the nested "address" property and only proceed to access "city" if "address" is defined.

However, if TypeScript is aware that the "address" property does not exist, it will still raise an error during compilation. When the code is commented out, the compilation proceeds without errors and the execution behaves as expected.

The optional chaining operator allows us to access nested properties and objects within our data object safely.

If the property preceding the question mark is undefined, the code will not attempt to access subsequent properties, preventing runtime errors.

Essentially, behind the scenes, the optional chaining operator is compiled into an if statement that checks for the existence of the property before accessing it.

It's worth noting that this feature was also introduced in ES2020 in vanilla JavaScript.

Nullish coalescing

In addition to optional chaining, TypeScript provides another useful feature that assists in handling nullish data.

This feature is called nullish coalescing.

Bridge in the sky 🌉
Photo by raffaele brivio / Unsplash

Consider a scenario where you have some input data for which you are uncertain if it's null, undefined, or a valid value.

For instance, let's assume the user input could be null. In the provided code snippet, we explicitly assign the value null in TypeScript, allowing TypeScript to recognize it as null.

However, if you are retrieving the data through a DOM API or from a backend, you may not have prior knowledge, and TypeScript may be unaware of whether the value is null or not.

In cases where you need to store this data in another constant or variable, you may want to ensure that if the input is null, a fallback value is assigned to the "default nickname":

const userInput = null;
const definedNickname = userInput || 'default nickname';

You now have the option to use the logical OR operator to assign a default value if the first value is undefined or null, as it evaluates to false in those cases.

However, this approach has a limitation.

If the first value is an empty string instead of null or undefined, it will still be treated as false, causing the fallback value to be assigned.

If your intention is to preserve the user input or any non-null/non-undefined data, except for specifically null or undefined values, you need an alternative solution.

To handle null or undefined values differently while keeping other data intact, TypeScript provides another operator called the nullish coalescing operator, represented by double question marks (??):

const userInput = null;
const definedNickname = userInput ?? 'default nickname';

This feature allows you to handle null or undefined values specifically, excluding empty strings or zero.

If the value is null or undefined, the fallback value will be used. However, if the value is not null or undefined, the actual value will be used.

Same as for optional chaining, the nullish coalescing feature was also introduced in ES2020 in vanilla JavaScript.

This feature proves to be highly beneficial when working with such values, providing an elegant solution.

Conclusion

In conclusion, TypeScript's index properties empower developers to create flexible object structures without strict property constraints, while function overloads enable precise control over function signatures.

Optional chaining and nullish coalescing operators further enhance code robustness and readability by gracefully handling undefined values and simplifying conditional logic.

By leveraging these powerful features, developers can write more resilient and expressive TypeScript code, resulting in more reliable and maintainable applications.