View
logo
logo

Contatti

Labs 1 August 2023

TypeScript: 5 More Tricks for Development

Writen by odc-admin

comments 0

sviluppo-typescript

Are you a web developer? Did you happen to work on a fairly complex JavaScript project, such as a Node.js server or a nice and complicated React website? Have the limitations in the programming language ever caused you to lose a lot of time and sanity in order to track down particularly sneaky bugs?

Well, all of the above happened to us. Many times, actually. Enough to make me wonder if there is any better tool for developing and maintaining web projects. Something that makes it easier to find errors while using data structures, for instance.

Luckily, the answer is yes, and this tool is TypeScript! Open source syntactical superset of JavaScript developed mostly by Microsoft, TypeScript adds to every browser’s favorite programming language a lot of new tools and features, mainly static typing, to make web projects easier to build and maintain.

You most likely already know all of this, especially if you often hang out on this blog. Not only because of the incredible popularity reached by TypeScript in the last few years, but also because right on this website there’s already an article of mine discussing some interesting features of this language. Back when I wrote it I talked about a potential sequel, and here it pops up just like a “Cannot read property of undefined” error in a JavaScript program.

Let’s get into it, then. Five sections, five TypeScript tools you may or may not know, with full examples and links to the official playground for version 5.1. Here we go.

Tuple

This one is really simple, but I bet it’ll be new to somebody.

Everyone who works with TypeScript knows how to statically type arrays: you just set the type of the elements, and then add the square brackets afterwards.

const numberArray: number[] = [];

// Valid:
numberArray.push(1);
numberArray.push(2);

// Not valid:
numberArray.push("string");

Try it on the playground

What’s not so obvious is that you can use type definitions as a simple way to create tuples – already mentioned in the previous article -, meaning ordered lists of non-unique elements. Here’s a tuple of three numbers.

// Valid:
const numberTuple1: [number, number, number] = [1, 2, 3];

// Not valid:
const numberTuple2: [number, number, number] = [];

// Not valid:
const numberTuple3: [number, number, number] = [1, 2, 3, 4];

Try it on the playground

The good thing about tuples is that, as you can see from the snippet, static typing allows TypeScript to validate the cardinality of the list, not just the type of its elements. This ensures that the data structure always contains exactly the elements you expect in the positions you expect.

Tuples have plenty of applications in JavaScript projects. In React, for instance, they’re useful for defining state variables that keep in one simple structure a bunch of values tightly related to one another, so you can easily read and update it without making the related component too verbose, especially with hooks.

Finally, it’s worth pointing out that tuples can contain diverse data of any type, even complex ones – including other tuples, if you want to be creative!

interface ResponseBody {
  title: string
  content: string
}

// This tuple contains an HTTP code, a 
// response message and the response body.
const apiResponse: [number, string, ResponseBody] = [
  200,
  "success",
  {
    title: "Title",
    content: "Content",
  },
];

// Getting the items with a destructuring assignment
// (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment).
const [statusCode, message, body] = apiResponse;

console.log(statusCode);
console.log(message);
console.log(body);

Try it on the playground

Utility types: Readonly e NonNullable

Utility types! Remember them? I already talked about a couple of these type modifiers in the previous article. Today we’ll look at two more of them, often overlooked but far from useless.

Readonly is a modifier that creates a read only version of a given type. If you have the interface for an object, for example, this utility type can make sure none of the properties can be reassigned after the object is created.

// Interface for an item with an ID and a title.
interface SomeInterface {
  id: number

  title: string
}

// SomeInterface object:
const someObject: SomeInterface = {
  id: 1,
  title: "Title",
};

// Readonly SomeInterface object:
const someReadonlyObject: Readonly<SomeInterface> = {
  id: 2,
  title: "Readonly title",
};

// Valid:
someObject.title = "Another title";

// Not valid:
someReadonlyObject = "Yet another title";

Provalo sul playground

This can look sort of useless, but think about all the times you deal with values that should be read only: objects that contain some sort of configuration parameters, the state of a React component, the state of a Redux store or data related to any library that relies on regular JavaScript comparisons in order to trigger some event in your application. Thanks to Readonly, TypeScript itself can help you deal with this data by throwing an error whenever you reassign a value that shouldn’t be reassigned. It’s like const, but deeper!

NonNullable is a modifier that excludes null or undefined from a union type. I don’t think this one needs much of an explanation: it’s useful when you have a type, like the type of an interface property (you do remember you can access the type of a property, right?), that can be empty but you need to initialize it to a non-empty value.

interface SomeInterface {
  // In this interface, value may be empty.
  value: number | null | undefined
}

// I'll initialize a variable I'll use as my value,
// but this time I need it to not be empty.

// Valid:
const val1: SomeInterface["value"] = null;

// Not valid:
const val2: NonNullable<SomeInterface["value"]> = null;

// Valid:
const val3: NonNullable<SomeInterface["value"]> = 10;

Try it on the playground

Generic types: default type, constraints, conditional types

Whoever worked with programming languages like Java or C# – also known as Microsoft Java – definitely knows what generics are. For anybody else, meaning all developers who still have some of their sanity, generics allow you to create software components that may accept a variety of different types, with the specific type provided by the component user, while still taking advantage of static typing.

// Let's say you need to write a function that takes any input parameter 
// and returns it. Without generics, this is the best you can do.
function identityAny(param: any): any {
  return param;
}

// But *with* generics:
function identity<T>(param: T): T {
  return param;
}

// Valid:
console.log(identity<string>("string"));
console.log(identity<number>(42));

// Not valid:
console.log(identity<boolean>(11));

// You can use them for interfaces...
interface GenericInterface<T1, T2> {
  param1: T1

  param2: T2
}

// ...classes...
class GenericClass<P> {
  prop: P

  constructor(propValue: P) {
    this.prop = propValue;
  }

  getProp(): P {
    return this.prop;
  }
}

// ...functions...
function genericFunction<P>(param: P): P {
  return param;
}

// ...and even arrow functions.
const genericArrowFunction = <P,>(param: P): P => param;

Try it on the playground

The official docs extensively talk about generics, so if you need a more lengthy introduction I recommend giving them a read. In this article, I’ll just mention some interesting things about the topic.

For instance: generic types can be made optional by providing a default value in their declaration, just like with the input parameters of a function. If no type is specified when the component is used, TypeScript will fall back to the default one.

// string is the default type for this interface.
interface GenericInterface<T = string> {
  param: T
}

// Valid:
const value1: GenericInterface = {
  param: "string"
}
const value2: GenericInterface<number> = {
  param: 42
}

// Not valid: TypeScript assumes param is typed as a string.
const value3: GenericInterface = {
  param: 42
}

Try it on the playground

This feature can be used to introduce generic types into a component that currently has none, or to add more of them next to existing ones, while also keeping said component backward compatible.

Be careful though: setting a default type doesn’t mean constraining the types that can be actually provided upon using the component. The type the user developer provides may be completely different from the default one, as you saw earlier with number and string.

That’s why it’d be a mistake to write something like this.

// An interface of the project.
interface SomeInterface {
  id: number
  
  name: string
}

// This function has a generic type, with the interface as its default.
function someFunction<T = SomeInterface>(param: T): void {
  // Not valid: there's no way to be sure T and SomeInterface will be compatible.
  console.log(param.name);
}

Try it on the playground

However, it actually is possible to set a constraint for the types your component will be able to receive. You can do that with the extends keyword, which may also be used along with a default type.

// An interface of the project.
interface SomeInterface {
  id: number
  
  name: string
}

// This function has a generic type that extends SomeInterface.
function someFunction<T extends SomeInterface = SomeInterface>(
  param: T
): void {
  // Valid: whatever the actual type of T is,
  // it will be compatible with SomeInterface.
  console.log(param.name);
}

// Not valid: number is not compatible with SomeInterface.
someFunction<number>(42);

Try it on the playground

For complex cases, you can also use conditions to change the static typing for other parts of your components according to the provided types. A classic example: let’s say you have a component that deals with a value, and this value could either be a single item or a list of items with the same type.

With generics, you’re able to handle everything with just one interface like this:

  • create an interface with two generic types, the type of the item T and a (constrained) boolean type Multiple;
  • define the value property and the onChange callback, called whenever the value changes;
  • set a condition for the type of the properties so it changes according to the type provided for Multiple by using extends.

Here’s the result.

// Multiple constrained on a boolean type, non-multiple as a default.
interface ComponentProps<T, Multiple extends boolean = false> {
  // The value is a single item or an array.
  value: Multiple extends false ? T : T[]

  // onChange gets a single item or an array as an input parameter.
  onChange: (newValue: Multiple extends false ? T : T[]) => void
}

// Object for the non-multiple case (using the default for Multiple):
const singleValueComponentProps: ComponentProps<string> = {
  value: "I'm just one string!",

  onChange: (newValue) => {
    console.log(
      "This will always print TRUE:", 
      typeof newValue === "string"
    );
  },
};

// Object for the multiple case:
const multipleValueComponentProps: ComponentProps<string, true> = {
  value: ["I'm", "an", "array", "of", "strings", "now!"],

  onChange: (newValue) => {
    console.log(
      "Safely using the array methods, since newValue is an array:"
    );

    newValue.forEach((currentValue) => console.log(currentValue));
  },
}

Try it on the playground

Type narrowing: type predicates

After mentioning it in the first article, let’s talk some more about type narrowing. And today, let’s try and give this extremely important feature the respect it deserves.

First off, a recap of what I said last time while discussing discriminated unions: type narrowing is basically the mechanism that allows TypeScript to infer what type a variable will be at runtime in a specific part of the code, using the static typing you defined and the flow of your program, so the transpiler can present the appropriate errors to you while turning your code into JavaScript. That’s all fine and good, but what I did not emphasize last time is how freaking powerful and ubiquitous type narrowing is in TypeScript, and how often we all take advantage of it without even realizing.

Think about a function with a parameter that can either be a string or a number. If it’s a string, you want to display its length; if it’s a number, you want to display it with fixed-point notation. Most JavaScript developers will naturally end up writing something like this, using the typeof operator.

function someFunction (value: string | number): void {
  if (typeof value === "string") {
    // Printing the length:
    console.log("Length of the string:", value.length);
  } else {
    // Printing the value:
    console.log("Value:", value.toFixed());
  }
}

Try it on the playground

That’s simple, right? Nevertheless, a number of non-trivial things are happening beneath the surface.

In the function signature, value is typed as being either a number or a string, thanks to a lovely union type. In the branches of the if construct, though, the code takes advantage of features that aren’t common to these two types: in the then branch it uses length, which doesn’t exist for number; in the else branch it uses toFixed, which doesn’t exist for string.

If you try and remove the if, both of its former branches understandably throw an error.

function someFunction (value: string | number): void {
  // Printing the length:
  console.log("Length of the string:", value.length);
  // Printing the value:
  console.log("Value:", value.toFixed());
}

Try it on the playground

And yet, TypeScript doesn’t have any issue in the first version of the snippet. What’s happening here?

The answer is type narrowing. TypeScript analyzes the flow of your code, and infers that, while executing a specific line, the type of a value will be more restrictive than the one you explicitly declared.

The typeof operator, for instance, is one of the so-called type guards, meaning special checks which have an effect on the typing deduced by TypeScript for a given value. That’s why the first version of the snippet works: starting from the union type string | number, TypeScript looks at the if condition and realizes that value will be a string for the duration of the then branch and, by exclusion, it will be a number in the else branch.

Type narrowing is a crucial feature in TypeScript projects, and it applies to a lot of different operators and constructs: typeof operators, comparisons, assignments, in operators, instanceof operators, if constructs, switch constructs…

But you’re not looking for lengthy explanations about the inner workings of TypeScript. You’re here to find some tips you can use in your code and tell other people at parties in order to win respect and admiration from your friends, and I won’t let you down.

(Warning: knowing the following information may not actually lead to winning respect and admiration from your friends. The author of this article declines every responsibility for your poor social skills.)

Sometimes, taking advantage of type narrowing may be harder than usual. Sure, as long as we’re talking about primitive types such as strings or numbers, or built-in utilities such as Date, things are easy and straightforward, but… what about user-defined interfaces?

TypeScript interfaces, as useful as they are, have a pretty big downside: they don’t exist at runtime. Actually, they don’t exist at all, since JavaScript has no such construct at the time of writing. They’re only meant to help developers catch static type errors, but they don’t survive transpilation in any form. So no, One does not simply™ use instanceof with TypeScript interfaces; narrowing takes a bit of extra effort for those.

A pretty standard situation: you have an interface SomeInterface, a second interface SomeExtension which extends the former, and a function that receives a SomeInterface object as an input. Since inheritance is in place, the input parameter may be a SomeExtension object; let’s say you want to perform some additional operation if that happens. You may try and use the in operator to check for an extension property, but sadly it won’t be enough.

interface SomeInterface {
  id: number

  title: string
}

interface SomeExtension extends SomeInterface {
  description: string

  content: string

  otherFields: Record<string, any>
}

function someFunction (obj: SomeInterface): void {
  // Printing every property to the console.
  // Valid:
  console.log(obj.id);
  console.log(obj.title);

  if ("description" in obj) {
    // The in operator let us use description...
    console.log(obj.description);

    // ...but it's not enough to convince 
    // TypeScript that obj has type SomeExtension.
    console.log(obj.content);
    console.log(obj.otherFields);
  }
}

Try it on the playground

(Up to TypeScript 4.8, even accessing description this way would have resulted in an error. Starting from version 4.9, type narrowing was slightly reworked, meaning you can access the property you explicitly check with in, even though the inferred type for it will be unknown.)

What you need to do here is make TypeScript understand that you’re rightfully accessing the extension properties, or, in other words, that you’re using those properties only when obj actually has type SomeExtension. Fortunately, this can be done with type predicates.

Type predicates let you define custom type guards, based upon arbitrarily complex conditions, which assert that a specific variable will be the given type in a specific code block – just like with typeof, but the logic is entirely defined by the developer.

So, here’s how you can solve the extension problem: create a function (isSomeExtension) that receives a SomeInterface object, and returns a type predicate asserting the input parameter is a SomeExtension object. The function body has to examine the input parameter to establish if the predicate is correct – or, as someone could say, if the parameter walks and quacks – and return true if it is, and false if it isn’t.

Finally, use that function inside the if condition for someFunction.

interface SomeInterface {
  id: number

  title: string
}

interface SomeExtension extends SomeInterface {
  description: string

  content: string

  otherFields: Record<string, any>
}

function isSomeExtension (value: SomeInterface): value is SomeExtension {
  // If value contains description, it's a SomeExtension object.
  return "description" in value;
}

function someFunction (obj: SomeInterface): void {
  // Printing every property to the console.
  // Valid:
  console.log(obj.id);
  console.log(obj.title);

  if (isSomeExtension(obj)) {
    // Valid: TypeScript acknowledges obj is a 
    // SomeExtension object in this part of the code.
    console.log(obj.description);
    console.log(obj.content);
    console.log(obj.otherFields);
  }
}

Try it on the playground

Yay!

An important note to close this long paragraph: keep in mind that type predicates let you pretty much “skip” the built-in type checking system. When a type predicate is in place, TypeScript will entirely trust your own type guard. That means you have to be extra careful while writing the function body: if something’s wrong in there, you won’t realize it until you notice a bug at runtime.

Function overloading

If you’re used to programming languages like Java, you should be familiar with method overloading. It’s a feature that allows you to create different methods with the same name and a different parameter list inside a class or an interface. That way, you’ll be able to use every different method according to the input parameters.

Did you know that a very similar – and useful! – feature exists in TypeScript? That feature is function overloading, and it can be used both on base functions and in class methods.

It works pretty much the same as method overloading, but with one key difference: while method overloading allows you to actually create multiple methods with different bodies, function overloading requires you to create just one function, and allows you to associate multiple signatures to it. This means you’ll have to write the function itself so that it’s compatible with every possible signature in order to avoid a type error.

// Multiple signatures:
function someFunction (param: number): number;
function someFunction (param: string): string;

// Implementation of the function:
function someFunction (
  param: number | string
): number | string {
  if (typeof param === "number") {
    console.log("It's a number!");
  } else {
    console.log("It's a string!");
  }

  return param;
}

const val1 = someFunction(42);
const val2 = someFunction(
  "So long, and thanks for all the fish"
);

Try it on the playground

Having to define only one function body for every signature may seem very limiting, but keep in mind: by declaring your function like this, TypeScript will be able to correctly infer the output type according to the input parameters type. In other words, you won’t have to manually type narrow the result returned by your function.

To demonstrate how helpful this can be, let’s add a few lines to the previous snippet.

// Multiple signatures:
function someFunction (param: number): number;
function someFunction (param: string): string;

// Implementation of the function:
function someFunction (
  param: number | string
): number | string {
  if (typeof param === "number") {
    console.log("It's a number!");
  } else {
    console.log("It's a string!");
  }

  return param;
}

const val1 = someFunction(42);
const val2 = someFunction(
  "So long, and thanks for all the fish"
);

// Valid:
console.log(val1.toFixed());
console.log(val2.length);

Try it on the playground

In the example above, val1 and val2 are used as a number and as a string respectively. You’re able to do this without any error specifically because of function overloading: TypeScript can tell that someFunction will return a number when it receives a number and a string when it receives a string.

Just for comparison, here’s how things would have changed without using function overloading.

function someFunction (
  param: number | string
): number | string {
  if (typeof param === "number") {
    console.log("It's a number!");
  } else {
    console.log("It's a string!");
  }

  return param;
}

const val1 = someFunction(42);
const val2 = someFunction(
  "So long, and thanks for all the fish"
);

// Not valid: TypeScript is unable
// to infer the output type.
console.log(val1.toFixed());
console.log(val2.length);

// We have to manually type
// narrow the variables:
if (typeof val1 === "number") {
  console.log(val1.toFixed);
}
if (typeof val2 === "string") {
  console.log(val2.length);
}

Try it on the playground

With even this simple snippet becoming a lot more verbose than before, you can imagine the impact this feature could have on a complex project.

There are several situations where function overloading can be useful. For instance, you can use it when you have a function that accepts either a single value or an array of values as a parameter.

function multiplyValue(
  value: number, 
  multiplyBy: number,
): number;
function multiplyValue(
  value: number[], 
  multiplyBy: number,
): number[];

// Multiplies a single value of an array of values 
// by the second parameter and returns the result.
function multiplyValue(
  value: number | number[], 
  multiplyBy: number,
): number | number[] {
  if (Array.isArray(value)) {
    const result = [];

    for (let i = 0; i < value.length; i++) {
      result.push(value[i] * multiplyBy);
    }

    return result;
  } else {
    return value * multiplyBy;
  }
}

const singleValue = multiplyValue(11, 2);
const arrayOfValues = multiplyValue(
  [1, 1, 2, 3, 5],
   5
);

// singleValue is a number:
console.log(singleValue.toFixed());

// arrayOfValues is an array of numbers:
arrayOfValues.forEach((item) => {
  console.log(item.toFixed())
});

Try it on the playground

(Notice how Array.isArray is a valid type guard, as well.)

No type guard necessary outside of the function body! Isn’t it lovely to be able to type less code?

Closing words

That closes this second article about TypeScript (didn’t read the first part yet? What are you waiting for?!). I hope I’ve been able to make you discover something new, or perhaps to shine some light on a few mechanics many developers never really think about.

If you have some secret TypeScript tools you’d like to share, I can’t wait to know about them. Will a third article ever be published? Who knows!

Andrea Cioni.

Tags :