TypeScript Learning Adventures: A Tale of Love and Hate - Aliases, Function types, Utility types

Here, I will talk about unions, aliases, function types, and utility types that Typescript provides. So let's start with union types.

TypeScript Learning Adventures: A Tale of Love and Hate - Aliases, Function types, Utility types
Photo by Deon Black / Unsplash

This article is the third part of the Typescript article series.

Here, I will talk about unions, aliases, function types, and utility types that Typescript provides.

So let's start with union types.

Union types

Let's say that you need to support multiple types on some variable or property. For example, you have a package object like this:

const package: {
  title: string;
  weight: number;
  trackingNumber: number;
} = {
  title: "books",
  trackingNumber: 123,
};

This works fine, but then the client decided to introduce the changes to the tracking number so now it also supports text like "123-45". They also want to support legacy tracking numbers which can be pure numbers.

Now to apply this change, you can use union types

const package: {
  title: string;
  weight: number;
  trackingNumber: number | string;
} = {
  id: 1,
  title: "books",
  trackingNumber: "123-45",
};

To tell TypeScript that we are fine with either a number or a string, we use the number and then the pipe symbol, and then the other type we also accept.

You can have more than two types, you can accept as many types here as you need.

Also be careful when you have situations like these when you need to do some operation on union types, like + or - but you have different types. So you can do something like this:

if (typeof package.trackingNumber === "string") {
  //do the string logic
} else if (typeof package.trackingNumber === "number") {
  //do the number logic
}

You will certainly also encounter situations in TypeScript programs where you can use a union type without a runtime type check. It depends on the logic you're writing.

Literal types

Very similar to Union types but literals are related to values. Literal types are the types that are based on your core types string, number, and so on but then you have a specific version of the type.

So, one more time the client requested a change to our package delivery project. Now they want to add package size. Size can be described as:

  • small
  • medium
  • large

So we can describe this in literal type like this:

const package: {
  title: string;
  weight: number;
  trackingNumber: number | string;
  size: 'small' | 'medium' | 'large'
} = {
  title: "books",
  weight: 4,
  trackingNumber: "123-45",
  size: 'medium',
};

So here, we allow specifically these three strings, not any string, but just these three strings.

So we want a string for result conversion, but it has to be one of these three values. Any other string value will not be allowed, and that's the idea behind a literal type.

Often you will use this in the context of the union type because you don't just want to allow one exact value you could hard code it into your code if that would be the case but you want to have two or more possible values.

Typescript will also throw an error if you try to assign any other string value:

package.size = 's';
//Type 's' is not assignable to type '"small" | "medium" | "large"'

Aliases

Now when working with union types, like this:

trackingNumber: number | string;
size: 'small' | 'medium' | 'large'

it can be cumbersome to always repeat the union type. You might want to make a new type that reinstalls this union type. You can do that with another cool type feature, the feature of type aliases.

You create such an alias, typically before you use it, so here at the top of the file in this case here, with the type keyword:

type TrackingNumber = number | string;
type PackageSize = 'small' | 'medium' | 'large';

const package: {
  title: string;
  weight: number;
  trackingNumber: TrackingNumber;
  size: PackageSize
} = {
  title: "books",
  weight: 4,
  trackingNumber: "123-45",
  size: 'medium',
};

The type keyword is not built into JavaScript, it's supported in TypeScript though, and after type, you add the name of your custom type or your type alias, I should say.

You're not limited to storing union types though - you can also provide an alias to a (possibly complex) object type:

type TrackingNumber = number | string;
type PackageSize = 'small' | 'medium' | 'large';
type Package = {
  title: string;
  weight: number;
  trackingNumber: TrackingNumber;
  size: PackageSize
}

const package: Package = {
  title: "books",
  weight: 4,
  trackingNumber: "123-45",
  size: 'medium',
};

Functions as types

You already know that functions are first-class citizens in JavaScript.

In Typescript you can specify the type of the function return value, as well as the function parameters:

function createPackage(
  title: string,
  weight: number,
  trackingNumber: TrackingNumber,
  size: PackageSize
): Package {
  return {
    title,
    weight,
    trackingNumber,
    size,
  };
}

If your function doesn't return any value, this means its return value is void:

function printPackage(package: Package): void {
  console.log(
    `title: ${package.title}
    weight: ${package.weight}
    trackingNumber: ${package.trackingNumber}
    size: ${package.size}
    `
  );
}

Let's say you want to store this function in a variable, you can specify something like this:

let packaging: Function;
packaging = createPackage;
packaging = printPackage;

The Function is a type provided by TypeScript, and this makes it clear that whatever we store here has to be a function.

So this is good, but it's not perfect because now we say this should be a function, but it could also set createPackage equal to printPackage.

And of course, TypeScript would not complain because printPackage is a function, but of course, it's not a function that takes 4 arguments.

And that's where function types come into play:

let packaging: (
  title: string,
  weight: number,
  trackingNumber: TrackingNumber,
  size: PackageSize
) => Package;

// this is ok
packaging = createPackage; 

// this will produce an error
// type (package: Package) => void is not assignable to type (title: string,
  weight: number,
  trackingNumber: TrackingNumber,
  size: PackageSize) => Package
packaging = printPackage; 

Function types are types that describe a function in terms of the parameters and the return value of that function.

A function type is created with this arrow function notation you know from JavaScript or at least close to that notation.

You don't add curly braces here because we aren't creating an arrow function here, we are creating a function type instead.

Now inside the braces, you specify the function type parameters, while on the right side of this arrow, you specify the return type of the function.

And that's why TypeScript does not complain about us storing createPackage in the packaging variable, because createPackage is a function that perfectly satisfies this type definition.

But it does complain about printPackage because K as it tells us here, is a function of type one argument which is a Package and nothing is returned where as we expect to get a function with four arguments and we also return an object with Package type.

So we have a mismatch here, and if I would try to compile this, we therefore would get an error here.

Partial

TypeScript provides a utility type called Partial which enables the creation of a new type by making all the properties of an existing type optional.

This allows the definition of an interface or type with mandatory properties and then using Partial to create a new type with the same properties, but with the added benefit of being able to exclude any of them as necessary.

This is especially beneficial in scenarios where a type needs to have optional properties but specifying each one individually is impractical.

type TrackingNumber = number | string;
type PackageSize = 'small' | 'medium' | 'large';
type Package = {
  title: string;
  weight: number;
  trackingNumber: TrackingNumber;
  size: PackageSize
}

type PartialPackage = Partial<Package>
const untrackedPackage: PartialPackage = {
  title: 'shirt',
  weight: 1,
  size: 'small',
}

Notice how this package doesn't have trackingNumber?

Well, that's because all properties in PartialPackage are optional. With Partial, you can simply define the required properties and then use the new type to create objects that may or may not include all of those properties

Required

In TypeScript, the Required type is a utility type that enables the creation of a new type by making all properties of an existing type mandatory.

With this, one can define an interface or type with optional properties and then use Required to generate a new type that retains all the properties while imposing the condition that they must be present.

This is beneficial when there is a need to enforce a particular object structure and make sure that all required properties are included.

type TrackingNumber = number | string;
type PackageSize = 'small' | 'medium' | 'large';
type Package = {
  title?: string;
  weight?: number;
  trackingNumber: TrackingNumber;
  size: PackageSize
}

type RequiredPackage = Required<Package>;
const requiredPackage: RequiredPackage = {
  title: "shirt",
  size: "small",
  trackingNumber: "123",
};

// this will produce error: Property 'weight' is missing in type '{ title: string; size: "small"; trackingNumber: string; }' but required in type 
'Required<Package>'.

Notice, how TypeScript demands to add weight property and its value?

With Required, you can define an interface with optional properties and then convert it to a new type that has all properties required, making it easier to catch errors at compile time and ensure that your code is robust.

Omit

The Omit type in TypeScript is a utility type that enables the creation of a new type by excluding one or more properties from an existing type.

This implies that one can define an interface or type with numerous properties and utilize Omit  to construct a new type that maintains all the properties, except for the ones designated for exclusion.

This is advantageous when there is a need to create a new type that is essentially identical to an existing one, but with a few properties omitted.

type TrackingNumber = number | string;
type PackageSize = 'small' | 'medium' | 'large';
type Package = {
  title: string;
  weight: number;
  trackingNumber: TrackingNumber;
  size: PackageSize
}

type NoTitlePackage = Omit<Package, 'title'>;
const noTitlePackage: NoTitlePackage = {
  title: "shirt",
  weight: 1,
  size: "small",
  trackingNumber: "123",
};

// this will produce an error, Object literal may only specify known properties, and 'title' does not exist in type 'NoTitlePackage'

With Omit, you can easily create a new type that omits the properties you don't need, making your code more concise and easier to read.

Also, keep in mind that Omit is just a TypeScript type, it is not JavaScript functionality. What I mean by that is that you can not use it to filter out unwanted properties from data:

type TrackingNumber = number | string;
type PackageSize = "small" | "medium" | "large";
type Package = {
  weight: number;
  title: string;
  trackingNumber: TrackingNumber;
  size: PackageSize;
};
const fullPackage: Package = {
  title: "My package",
  weight: 2,
  size: "small",
  trackingNumber: 101,
};

type NoTitlePackage = Omit<Package, 'title'>;

const noTitlePackage: NoTitlePackage = {...fullPackage}
console.log(noTitlePackage)

// prints
// {
//   size: "small",
//   title: "My package",
//   trackingNumber: 101,
//   weight: 2
// }

So keep this in mind. If you need to remove properties, use your own solution, like destructuring or lodash library.

Pick

TypeScript provides the Pick type as a utility type that enables the creation of a new type by selecting one or more properties from an existing type.

Consequently, it is possible to define an interface or type with several properties and employ Pick to generate a new type that retains only the designated properties.

type TrackingNumber = number | string;
type PackageSize = 'small' | 'medium' | 'large';
type Package = {
  title: string;
  weight: number;
  trackingNumber: TrackingNumber;
  size: PackageSize
}

type OnlyTrackingPackage = Pick<Package, "trackingNumber">;
const noTitlePackage: OnlyTrackingPackage = {
  trackingNumber: "123",
};

The application of Pick type can be beneficial in cases where one needs to deal with a subset of a current type's properties.

Utilization of Pick  enables the creation of a new type that only contains the required properties, which can make the code less complex and more comprehensible.

Moreover, Pick  can enhance type safety as the resulting type includes only the designated properties. Hence, any attempt to access a property that is not part of the new type will lead to a TypeScript error.

Record

TypeScript offers the Record type as a utility type that permits the creation of a new type with predetermined keys and values.

This implies that it is possible to define a type with a set of keys and a common value type for all the keys. Using Record  one can easily generate a new type that includes the designated keys, each with the common value type.

type PackageSize = "small" | "medium" | "large";
type InventoryPackages = Record<PackageSize, number>;
const inventory: InventoryPackages = { small: 5, medium: 3, large: 2 };

The Record  type can be valuable in cases where there is a need to define a type with a fixed set of properties that share the same data type, such as an object with identical property types.

Record  can also be applied to specify more intricate types, such as a dictionary that maps a set of keys to values with varying types.

By utilizing Record  it is possible to create a type that accurately reflects the key-value relationships, resulting in more type-safe code that is more convenient to operate.

Any

In TypeScript, the any type is a special type that represents a value that can be of any type.

When you declare a variable with the any type, you are essentially telling TypeScript that you don't care about its type and that it can be anything.

While the any type can be useful in some situations, but it can also be problematic, as it essentially disables TypeScript's type-checking for that variable.

function add(n: any){
  return n + 5;
}

add(5);    // returns 10
add('5');  // returns '55'

This can lead to errors at runtime, as you may end up using the variable in ways that are not compatible with its actual type. In general, it's recommended to avoid using the any type whenever possible and instead try to define more specific types for your variables and functions.

I would say you can consider yourself a good TypeScript developer when you don't have a single Any used in the project.

Unknown

In TypeScript, the unknown type represents a value whose type is not known.

This type is similar to the any type, but with stricter type checking. When you declare a variable with the unknown type, you are essentially telling TypeScript that you don't know the type of the value and that it could be anything.

However, unlike the any type, you cannot use the value of an unknown variable directly without first performing some sort of type checking or type assertion.

let unknownTrackingNumber: unknown;

// this is ok
const anyTrackingNumber: any = unknownTrackingNumber; 

// this is producing an error: Type 'unknown' is not assignable to type 'string'.
const textTrackingNumber: string = unknownTrackingNumber; 

// but it can be fixed with if condition
if (typeof unknownTrackingNumber === "string") {
  const textTrackingNumber: string = unknownTrackingNumber;
}

This can help catch errors at compile time and ensure that your code is more type-safe.

The unknown type is a useful tool for situations where you don't know the type of a value, but need to work with it in a type-safe way.

That's it for this part, stay tuned for the next one! :)