View
logo
logo

Contatti

Labs Software Development 21 December 2022

5 useful TypeScript tips & tricks

Writen by odc-admin

comments 0

We planned to release this article a long time ago, but we had some issues compiling it. We finally solved every type error, so here we go.

TypeScript

Oh, TypeScript. The open source programming language developed by Microsoft as a superset of JavaScript doesn’t need any introduction at this point. With tools like React, Angular and Node.js being more and more popular every day, the need to develop robust and easy-to-maintain JavaScript applications is impossible to ignore, and the static typing provided by TypeScript helps a lot in that regard.

If you’re part of the increasingly small group of developers who are yet to adopt TypeScript in their daily work, don’t worry: if you know JavaScript, its statically typed cousin is not difficult to approach. Being a superset, TypeScript can natively compile any JavaScript code – with the exception of some type error -, and allows you to start gradually using its features.

The purpose of this article is not to introduce TypeScript, which would probably be redundant for many of you. Instead, I want to show off some interesting features I learned while working with Anders Hejlsberg’s language. TypeScript offers a lot of tools besides the most obvious ones, and they may surprise even developers with a fair share of experience with it. They can be useful to make our projects even more robust and readable or to save some unnecessary code.So, here’s 5 tips and tricks you can use with TypeScript, starting with the most popular and well-known and then going down to the most obscure and situational ones, all tested on the current version of the language (4.8) and with full examples on the official playground.

Utility type: Omit e Pick

Utility types are utilities – duh – globally available in every TypeScript application. They are tools that, given a type A, like an interface, can create a type B as a somewhat modified version of A.

There are a lot of utility types and they’re well documented in the official documentation, so in this article I’ll just show off two of the ones I’ve ended up using the most in our own projects. Omit is a utility type that, given a type A with a bunch of properties, creates a type B with all the properties from A except the ones provided in its definition.

// Starting type:
interface TypeA {
  id: string

  name: string

  description: string

  price: number
}

// New type created from the starting one, excluding the properties description and price:
type TypeB = Omit<TypeA, "description" | "price">;

// Creating a TypeB object:
const objB: TypeB = {
  id: "1001", // OK.

  name: "Name", // OK.

  description: "Description", // Invalid: this property doesn't exist in TypeB.
}

Try it out on playground

This utility can be really useful if you need to create partial objects of a specific type while also taking advantage of the constraints provided by the type itself, like the mandatory properties. Thus, Omit can be more robust and specific than Partial, which just makes every property of a type optional, although it’s a little harder to use.

Omit is also helpful for code reuse. Picture this: somewhere in your application, potentially in a library, there is a TextInputProps type, which is an interface for the property of a text input field. You want to create a new input type, which works the same as the other one but only handles numbers instead of strings. You’d like to be able to just extend TextInputProps to change the type for the property value, but…

interface TextInputProps {
  label: string

  name: string

  value: string
}

interface NumberInputProps extends TextInputProps {
  value: number
}

Try it out on playground

Oh no! You can’t extend the interface because the types aren’t compatible! What now? Do you have to replicate the whole interface, or create a new one to encapsulate the common fields?Not necessarily: Omit comes to your aid.

Instead of extending TextInputProps, you can extend a modified version which includes every property except for value. This allows you to redefine the property without any issue, even if you’re not meeting the constraint of the starting type. Keep in mind the new interface won’t be an actual extension of the starting one anymore in terms of object-oriented programming – in other words: NumberInputProps objects won’t be TextInputProps objects -, but it’s not a problem in this case.

interface TextInputProps {
  label: string

  name: string

  value: string
}

interface NumberInputProps extends Omit<TextInputProps, "value"> {
  value: number
}

Try it out on playground

One less thing to do before you can go home and turn on your PlayStation! Woo-ooh!*

As I’m sure you’ll have guessed, Pick works in the opposite way, meaning it creates a type B with only the given subset of type A, and it’s just as useful.

// Starting type:
interface TypeA {
  id: string

  name: string

  description: string

  price: number
}

// New type created from the starting one, with just the id and name properties:
type TypeB = Pick<TypeA, "id" | "name">;

// Creating a TypeB object:
const objB: TypeB = {
  id: "1001", // OK.

  name: "Name", // OK.

  description: "Description", // Invalid: this property doesn't exist in TypeB.
}

Try it out on playground

(*Note for all the mr. and mrs. Know-it-all who were already typing their comments: yeah, I know, this problem would be way better solved with generics type. It was just an example to explain the feature, ok? Keep your hands off the keyboard and I won’t humiliate you in Elden Ring PVP tonight.)

Using the type of a property

This one is really simple, but still useful in order to avoid code replication.

Here’s a typical situation in a relatively complex TypeScript project: you have an interface, and this interface has a bunch of properties, each with its own type. You have to create a new object implementing this interface, but one of the values is difficult to calculate, and you want to use a different variable before setting it in the object itself. The question is: how can you give the correct type to said variable?

The easiest answer is to just repeat the type of the property when you declare the variable.

// Interface:
interface SomeInterface {
  id: number

  code: string

  name: string
}

// Declaring a variable in order to calculate the value for code:
let code: string; // <- Repeating the type

// An incredibly and overwhelmingly complex initialization logic:
code = "CODE";

const someObject: SomeInterface = {
  id: 1,
  code: code,
  name: "Name",
}

Try it out on playground

However, this repetition is actually unnecessary. When you have a type with some properties, such as an interface, TypeScript allows you to reference the type of a specific property by using the square bracket notation, just like you were accessing a common JavaScript object.

// Interface:
interface SomeInterface {
  id: number

  code: string

  name: string
}

// Declaring a variable in order to calculate the value for code:
let code: SomeInterface["code"]; // <- No repetition!

// An incredibly and overwhelmingly complex initialization logic:
code = "CODE";

const someObject: SomeInterface = {
  id: 1,
  code: code,
  name: "Name",
}

Try it out on playground

Only square brackets, though. Don’t try it with the dot notation.

With this solution, the interface will be the only one keeping the information about the type of the property, and if said type ever changed in the future you’d have one less line of code to worry about. Let’s be clear: if the property switches to a new type that’s incompatible with the old one you’ll still have to fix the initialization, but you won’t have to change the static type for the declaration of the variable.

Likewise, you can access the typing of the element in an array type by using the type of its index, meaning number.

// Interface:
interface SomeInterface {
  id: number

  code: string

  name: string
}

// Declaring an array of SomeInterface objects:
type SomeInterfaceArray = SomeInterface[];

// Using the array type to get the element type:
const someObject: SomeInterfaceArray[number] = {
  id: 1,
  code: "CODE",
  name: "Name",
}

Try it out on playground

Type narrowing: discriminated union

This is one of my personal favorites, and a very interesting one for those who are used to traditional object-oriented programming languages.

First of all: I don’t think union types are a mystery to any TypeScript developer. It’s the feature that allow you to create a new type by combining existing types with the pipe operator (|).

// Union type:
type SomeUnion = string | boolean;

// Valid:
const someVariable1: SomeUnion = "String";

// Valid:
const someVariable2: SomeUnion = false;

// Not valid:
const someVariable3: SomeUnion = 10;

Try it out on playground

Creating a union type using values instead of types is also a well-known feature, since scalar values are considered valid types by TypeScript. This means you can type a variable or a property to have one of a specific list of values, for example one of three strings.

// Union type:
type SomeUnion = "string_1" | "string_2" | "string_3";

// Valid:
const someVariable1: SomeUnion = "string_1";

// Not valid:
const someVariable2: SomeUnion = "string_5";

Try it out on playground

It’s all pretty boring so far, but give me a moment: here comes the fun part. Did you know you can use union types in order to discriminate between different types and take advantage of static typing for them? That’s a little convoluted, I know. Let’s make another example.

Let’s say your project calls or exposes a REST API. The API response structure includes:

  • a status, SUCCESS or FAIL;
  • the body data, whose type doesn’t matter here;
  • an error_message string, which gives the caller some description of any error occurred. The response will contain error_message only when its status is FAIL.

How could you model this structure into your project?Thinking about the typical object-oriented style, you could end up with an interface like the one below, with status that can assume either one of the two values and the optional field error_message.

interface ApiResponse {
  status: "SUCCESS" | "FAILURE"

  data: any

  // Will only be valued on FAILURE.
  error_message?: string
}

Try it out on playground

The disadvantage here is that you have no static validation for error_message. You have to make sure the field is only valued when it should, and if you do something wrong you won’t realize it before debugging.

interface ApiResponse {
  status: "SUCCESS" | "FAILURE"

  data: any

  // Will only be valued on FAILURE.
  error_message?: string
}

// Valid: the compiler is unable to tell this is wrong.
const response: ApiResponse = {
  status: "SUCCESS",
  data: {},
  error_message: "An error message"
}

Try it out on playground

Luckily, union types unlock a powerful alternative to solve this problem: discriminated unions, which are part of the broader type narrowing topic. A lot could be written on type narrowing, but let’s just say it’s the group of features that allows TypeScript to figure out the type of a certain variable given your type handling and the flow of your application. Let’s use discriminated unions to refactor the previous example like this.

  1. Instead of one interface, let’s create two: one for a successful response and one for a failure response.
  2. For ApiSuccessResponse, let’s type status so it can only have SUCCESS as a value, and let’s not include error_message at all. For ApiFailureResponse, status will only have FAIL as a value and error_message will be a mandatory string.
  3. Finally, let’s create a third interface ApiResponse as a union of the previous two.

Here it is.

interface ApiSuccessResponse {
  status: "SUCCESS"

  data: any
}

interface ApiFailureResponse {
  status: "FAIL"

  data: any

  error_message: string
}

type ApiResponse = ApiSuccessResponse | ApiFailureResponse;

Try it out on playground

With this typing, the compiler can discriminate between the different types of response, and will be able to statically tell you if error_message should be there, for both write and read access.

interface ApiSuccessResponse {
  status: "SUCCESS"

  data: any
}

interface ApiFailureResponse {
  status: "FAIL"

  data: any

  error_message: string
}

type ApiResponse = ApiSuccessResponse | ApiFailureResponse;

// Not valid: a SUCCESS response can't have an error_message.
const response1: ApiResponse = {
  status: "SUCCESS",
  data: {},
  error_message: "An error message",
}

// Not valid: a FAIL response must have an error_message.
const response2: ApiResponse = {
  status: "FAIL",
  data: {},
}

// Valid:
const response3: ApiResponse = {
  status: "SUCCESS",
  data: {},
}
const response4: ApiResponse = {
  status: "FAIL",
  data: {},
  error_message: "An error message",
}

Try it out on playground

Rassicurante, non è vero?

Const assertion (array of values as a union type)

Union types sure are handy, but there’s one significant downside to them: being a part of static typing, any data about the possible values a union type allows is lost during the compilation process and can’t be used at runtime. This means, among other things, that you can’t loop over the values of a union type, and there’s no way to check whether an untrusted value is allowed.

If your project needs a group of values you can use both statically and dynamically, you’re most likely better off using an enum. TypeScript enums allow you to define a group of named constants which are used by the compiler to check for the validity of types and, unlike union types, are also available at runtime.

However, a similar result can also be achieved with an array of scalar values, like an array of strings. To do that, you need to take advantage of the const assertion construct. What is that, you may ask. Why, thanks for the question, that’s just what I’m here for.So: whenever you declare and initialize a variable without explicitly typing it, TypeScript will infer an adequate type for said variable on your behalf according to the provided value. An array of strings will therefore be typed as a string[]. You can check that out yourself in the Playground: just click on the “.d.ts” tab.

const array = ["string_1", "string_2", "string_3"];

// Inferred type:
// declare const array: string[];

Try it out on playground

By using a const assertion, you can control this behavior. Const assertions ask TypeScript to infer the most restrictive type possible given the provided value. Thus, an array of strings initialized with a list of values will be typed as a read-only array, with a specific length, which contains specifically the provided values, each of them at a specific index – kind of a tuple, if you will.

const array = ["string_1", "string_2", "string_3"] as const;

// Inferred type:
// declare const array: readonly ["string_1", "string_2", "string_3"];

Try it out on playground

Such an array can still be used in the code, to loop over the values inside or to check if some other value appears in the list, but thanks to the const assertion you can also create a union type out of it.

const someArray = ["string_1", "string_2", "string_3"] as const;

type SomeUnion = typeof someArray[number];

// Valid:
const someVar1: SomeUnion = "string_1";

// Not valid:
const someVar2: SomeUnion = "string_5";

// You can still use someArray as a regular array:
if (someArray.indexOf("string_1") !== -1) {
  console.log("This is valid!");
}
for (let i = 0; i < someArray.length; i++) {
  console.log(someArray[i]);
}

Try it out on playground

This lets you combine the pros of having an array of values, really useful if you need Yup validation for instance, and all the good stuff coming from having a union type, like static typing for property values and autocompletion from your IDE.

Template/Dynamic union types

Wow, yet another tip on union types! What were the odds?

As we already saw, union types can be used in order to create variables or properties that can take one of a list of scalar values.

// Union type:
type SomeUnion = "string_1" | "string_2" | "string_3";

// Valid:
const someVariable1: SomeUnion = "string_1";

// Not valid:
const someVariable2: SomeUnion = "string_5";

Try it out on playground

This is useful, but kind of limiting, since you can only use the values in the list with no variation whatsoever. Everything that’s not part of the list will cause a type error and TypeScript will refuse to compile.

Now, let’s say you have a string that can take one value from a list that includes a bunch of very similar but not entirely static values. For instance, you may have an algorithm which makes a few attempts to perform a task that may fail, and you want to create a variable to keep the status of the algorithm.

Let’s create a status variable, its possible values being:

  • none when no attempt was performed;
  • success if the task was successfully completed;
  • fail if the task failed;
  • attempt_N, with N being the counter for the current attempt (attempt_1, attempt_2, attempt_3…), while the algorithm is running.

Using union types the old-fashioned way, you may end up explicitly writing down the static values and just leaving a generic string for the last ones…

// Union type for the status:
type StatusType = "none" | "success" | "fail" | string;

// This is valid...
const status1: StatusType = "none";
const status2: StatusType = "attempt_3";

// ...but this is valid as well.
const status3: StatusType = "Hey, I'm not a status at all!";

Try it out on playground

…but that’s not the only way. The string values defined for a union type can actually have dynamic parts, which we can control by simply concatenating dynamic parts and static parts with template strings.

// Union type for the status:
type StatusType = "none" | "success" | "fail" | `attempt_${number}`;

// This is valid...
const status1: StatusType = "none";
const status2: StatusType = "attempt_3";

// ...but this one isn't.
const status3: StatusType = "Aw :(";

Try it out on playground

Further examples can be found here.

We love you, union types <3

Closing words

Approaching TypeScript is an interesting experience for a developer used to traditional object-oriented programming languages. Being it a superset of a non-statically typed language such as JavaScript, the team developing it was able to find a lot of ways to tackle every sort of issue, and even after some time working with it you still happen to discover something new every once in a while.

I presented to you some features I found out to be useful during my work, hoping they may help you too. I’m sure I’ll find more of them in the future.

What do you think? Did you actually learn anything new, or did you already know about every tip? Do you have any of your own tips you’d like to show to the rest of us? Let me know, and maybe one day I’ll make a sequel!

Andrea Cioni

Tags :