Understanding and Utilising TypeScript, by example
Tags: typescript, javascript, webdev, tutorial
Published: 2023-01-16 13:36:57

TypeScript is a powerful, open-source programming language that is a strict superset of JavaScript. Developed and maintained by Microsoft, TypeScript adds optional static typing and other features to JavaScript, making it a popular choice for large-scale, complex projects. However, some developers have criticised TypeScript for its learning curve and added complexity, while others have praised it for improving code quality and catching errors early on. 

In this article, we will delve into examples of how to effectively use TypeScript in common scenarios so we can apply these concepts to our future projects. Primarily we'll be focusing on `primitives`, `types`, `interfaces` and `enumns`.

## Primitives, Types, Interfaces and Enums, what are they?


### Primitives

In TypeScript, primitive types are the basic data types that form the building blocks of the language. They include:

1. **number**: for numeric values (e.g., 1, 3.14, -100)
2. **string**: for text values (e.g., "hello", "world")
3. **boolean**: for true/false values
4. **null** and **undefined**: for empty or non-existent values
5. **symbol**: a new data type introduced in ECMAScript 6, used for creating unique identifiers

These primitive types have corresponding JavaScript literals and can be used to declare variables and constants. For example:

```ts
let myName: string = "John";
let myAge: number = 30;
let isStudent: boolean = false;
```
Also, `typeof` operator in TypeScript returns the type of a variable, which can be one of these primitive types.

```ts
console.log(typeof "hello");  // Output: string
console.log(typeof 100);     // Output: number
console.log(typeof true);    // Output: boolean
```

In addition, TypeScript provides some special types, like `any` and `unknown`.

1. **any**: `any` can be assigned to any variable and it will accept `any` value and `any` type,
2. **unknown**: `unknown` is like `any`, but more restrictive and can be used when you want to be more strict with type checking

### Types and Interfaces?


#### Types

Types in TypeScript are used to specify the expected data type of a variable or function parameter. They can be either primitive types, such as number, string, and boolean, or complex types, such as arrays, objects, and user-defined types. Types can be defined using the `type` keyword.

##### Type basics by example

```ts
type Brand = "audi" | "ford"; // Value must be "audi" or "ford"

type Car = {
  owner: string;
  milage: number;
  brand: Brand;
}

```

#### Interfaces

Interfaces provide a way to describe the structure of an object. They define a contract for the shape of an object, specifying the properties and methods it should have. Interfaces can be defined using the `interface` keyword.

##### Interface basics by example

```ts
interface ICar = {
  owner: string;
  milage: number;
  brand: Brand;
}

```

According to the official Typescript documentation:

> Almost all features of an interface are available in type, the key distinction is that a type cannot be re-opened to add new properties vs an interface which is always extendable.

You can read more about the differences between `types` and `interfaces` in the [official typescript documentation](https://www.typescriptlang.org/docs/handbook/advanced-types.html#interfaces-vs-type-aliases)

### Enums

In TypeScript, an `enum` is a way to define a set of named values that represent a specific set of constants.

You can define an enum using the `enum` keyword, followed by a name for the enum and a set of members enclosed in curly braces. Each member has a name and an associated value, which can be either a numeric literal or another enum member. The first member is assigned the value 0 by default, and each subsequent member is assigned the value of the previous member plus 1, unless explicitly specified.

```ts
enum EFuel {
  petrol,
  diesel,
}
```

You can also explicitly set the values of enum members:

```ts
enum EColor {
  "red" = "#ff0000",
  "blue" = "#0000ff",
}
```

## Naming conventions

### Common conventions

Typescript types, interfaces and enums should be defined using Pascal case. Pascal case, also known as upper camel case, is a naming convention where the first letter of each word in the name is capitalised, and there are no underscores between the words. 

### Personal conventions

#### Singluar / Plural

As a personal preference, I prefer for all types, interfaces, and enums to be named in the singular form.

```ts
const car: ICar = {/* values */} // Single car
const cars: ICar[] = {/* values */} // Multiple cars
```

#### Prefixes

For clarity and ease of identification, it can be beneficial to clearly distinguish between types, interfaces, and enumerations, particularly when importing them. To aid in this differentiation, I have adopted the convention of prefixing interfaces with an "I" and enum's with an "E", while leaving types without a prefix. This allows for quick and easy identification when navigating the codebase.

Different developers have their own conventions and have presented compelling arguments for and against various approaches. Ultimately, it is important to find the approach that works best for you and your team, and to be consistent in its application. This will help to ensure that your code is clear, readable, and maintainable.

```ts
interface ICar = {
  owner: string;
  milage: number;
  brand: Brand;
}

```

```ts
enum EColor {
  "red" = "#ff0000",
  "blue" = "#0000ff",
}
```

## Typescript by example

Now that we have a basic understanding, let's dive deeper into Typescript by examining its use in common scenarios, using a fictional car sales application as a practical example.

In our application each car should have 6 properties:

- `owner`
- `milage`
- `fuel`
- `color`
- `brand`
- `radioCode`

We could loosely type our car object with primitives: 

```ts
export interface ICar {
  owner: string;
  milage: number;
  fuel: string;
  color: string;
  brand: string;
  radioCode: string;
}
```

While this interface is technically valid, we can enhance its type safety by adding specific types to certain properties, such as `fuel` and `brand`. Let's take a closer look at how we can accomplish this. Given that fuel types and brands are limited to a specific set of options, we can improve the type safety by using enums for these properties:

```ts
enum EFuel {
  petrol,
  diesel,
}

enum EBrand {
  audi,
  ford,
}
```

Handling colors can be a bit more complex, as they typically have both a human-readable name and a HEX code. In this case, we can create an enum with key-value pairs to handle this. This allows us to ensure that we are only working with valid color options and also gives us the flexibility to easily reference and display the color name and code.

```ts
enum EColor {
  "red" = "#ff0000",
  "blue" = "#0000ff",
}
```

We can now reference the value of EFuel, EBrand and EColor much like we would an object property:

```ts
const fuel = EFuel.petrol; // Or EFuel["petrol"]
const brand = EBrand.audi; // Or EBrand["audi"]
const color = EColor.red; // Or EFuel["red"]
```

We can also create types from these enums using the `keyof` and `typeof` keywords. These types can be used to better define our ICar interface, replacing primitives, ensuring that only valid options are assigned to the relevant properties.:

```ts
type Fuel = keyof typeof EFuel; // "petrol" | "diesel"
type Brand = keyof typeof EBrand; // "audi" | "ford"
type Color = keyof typeof EColor; // "red" | "blue"
```

```ts
export interface ICar {
  owner: string;
  milage: number;
  fuel: Fuel;
  color: Color;
  brand: Brand;
  radioCode: string;
}
```

Similar to `enums`, we can also create types from look-up tables, let's imagine in our fictional application we have a radio codes look up table:

```ts
const radioCodes = {
    sony: "1111",
    pioneer: "2222"
};
```

Just like enums we can create a type from our `radioCodes` look-up table using the `keyof` and `typeof` keywords:

```ts
type RadioCode = keyof typeof radioCodes; // "sony" | "pioneer"
```

Type `RadioCode` can be used to better define property `radioCode`in our ICar interface, replacing `string`, ensuring that only valid options are assigned:

```ts
export interface ICar {
  owner: string;
  milage: number;
  fuel: Fuel;
  color: Color;
  brand: Brand;
  radioCode: RadioCode;
}
```

It's worth noting that in reality, not all cars are equipped with radios, which would leave the radioCode property undefined for those cars, this would break our application.
To ensure that our application is not broken by the presence of undefined values for `radioCode` we can make it optional by chaining `?` to the end of the key name:

```ts
export interface ICar {
  owner: string;
  milage: number;
  fuel: Fuel;
  color: Color;
  brand: Brand;
  radioCode?: RadioCode;
}
```

Currently, the `ICar` interface in our application requires a `string` value for the `owner` property. However, let's consider the possibility that our application has a predefined array of owners:

```ts
const people = ["Liam Hall", "Steve Jobs", "Ozzy Osborne", "Robert De Niro"];
```

Firstly we can type this array, we know that all the values in the array should be strings, so suffixing the `string` primitive with `[]` to to mark it as an array of type, we can ensure the const is strictly typed to an array of strings:

```ts
const people: string[] = ["Liam Hall", "Steve Jobs", "Ozzy Osborne", "Robert De Niro"];
```

With the array of `people` in place, we can now establish a more specific data type for the `owner` property on our `ICar` interface, making it more robust, ensuring only one of our predefined people can be set as a cars `owner`. To do this we use the `typeof` keyword:

```ts
type Person = typeof people[number];
```

Which we can now add to our `ICar` interface:

```ts
export interface ICar {
  owner: Person;
  milage: number;
  fuel: Fuel;
  color: Color;
  brand: Brand;
  radioCode?: RadioCode;
}
```

In reality, the `owner` property would typically provide additional information. Additionally, it's not uncommon for the owner of a car to be represented as a business entity, such as a garage, rather than an individual. A basic implementation of this concept using interfaces may appear as follows:

```ts
export interface IPerson {
  firstName: string;
  lastName: string;
}

export interface IGarage {
  name: string;
  companyNumber: number
}
```

Utilising these interfaces let's create some two new arrays, `people` and `garages`, replacing the previous array of strings:

```ts
const people: IPerson[] = [
  {firstName: "Liam", lastName: "Hall"},
  {firstName: "Steve", lastName: "Jobs"},
  {firstName: "Ozzy", lastName: "Osborne"},
  {firstName: "Robert", lastName: "De Niro"}
]

const garages: IGarage[] = [
  {name: "London Garage", companyNumber: 1111},
  {name: "New York Garage", companyNumber: 2222},
  {name: "Tokyo Garage", companyNumber: 3333},
  {name: "Lagos Garage", companyNumber: 4444}
  {name: "Berlin Garage", companyNumber: 5555}
]
```

Now using the `or` operator `|`, we can update our `ICar` `owner` property to accept types `IPerson` and `IGarage`:

```ts
export interface ICar {
  owner: IPerson | IGarage;
  milage: number;
  fuel: Fuel;
  color: Color;
  brand: Brand;
  radioCode?: RadioCode;
}
```

We can also use the `or` operator to create mixed type arrays:

```ts
const owners: (IPerson | IGarage)[] = [...people, ...garages];
```

In cases where a combination of different data types will be frequently utilised throughout the application, it can be beneficial to establish a parent type for them. This will provide a more organised and consistent approach for working with the mixed data:

```ts
type Owner = IPerson | IGarage;
```

Which will allow us to update `ICar`:

```ts
export interface ICar {
  owner: Owner;
  milage: number;
  fuel: Fuel;
  color: Color;
  brand: Brand;
  radioCode?: RadioCode;
}
```

In certain scenarios, it may be necessary to have an array with a fixed structure of data types, for example, where index `0` should be of the type `IGarage` interface, and index `1` should be of the type `IPerson` interface. In such situations, we can utilise Tuples to define the specific structure of the array:

```ts
const lastSale: [IGarage, IPerson] = [
  {name: "London Garage", companyNumber: 1111},
  {firstName: "Liam", lastName: "Hall"}
];
```

In the real world, cars can change hands between individuals and businesses, and in some cases, may never have been sold at all. To accommodate this complexity, we can use the Owner type that we previously created, and make use of the `or` operator along with the primitive `undefined`:

```ts
const lastSale: [(Owner | undefined), Owner] = [
  {name: "London Garage", companyNumber: 1111},
  {firstName: "Liam", lastName: "Hall"}
];
``` 

With a robust, strongly typed car interface in place for our fictional application, let's explore additional types that could enhance the functionality of our application.

Imagine that the application's designer has chosen to incorporate brand-specific accent colors. To accomplish this, we must establish a correspondence between brand names and colors. For this we can create a simple look-up table:

```ts
const brandAccents = {
  audi: "#000000",
  ford: "#333333"
}
```

This approach will function as intended, however, it currently lacks any form of type safety. To guarantee that keys are exclusively of the "Brand" type and colors are consistently represented as strings, what measures can we implement? To do this we can make use of the `key in` keywords:

```ts
type BrandAccentMap = {
  [key in Brand]: string;
}
```

We can now type `brandAccents` with `BrandAccentMap`:

```ts
const brandAccents: BrandAccentMap = {
  audi: "#000000",
  ford: "333333"
}
```

To ensure that all keys are of type `Brand` while allowing for flexibility that some brands may not have accent colors, we can make the brand key-value pair optional by appending `?` to the dynamic key, much like we did for `radioCode` in `ICar`:

```ts
type BrandAccentMap = {
  [key in Brand]?: string;
}
```

Of course, in the reality, world, most applications need to fetch data from a datasource. Let's consider a hypothetical scenario where our application requires retrieving a specific vehicle from a database, utilising a unique identifier such as an `id`:

```ts
const getCar = (id) => {/* fetch data */}

// OR
// function getCar(id) {/* fetch data */}
```

Although this function is functional, there are ample opportunities to enhance type safety. It would be beneficial to clearly define the types for both the argment, `id`, and the output of the function. In the context of our hypothetical application, the `id` argument should be specified as a `string`, and the function should return an `object` of type `ICar`:

```ts
const getCar = (id: string): ICar => {/* fetch data */}

// OR
// function getCar(id: string): ICar {/* fetch data */}
```

As demonstrated in the example, we can clearly define the types of function arguments by using a `argument: type` pair, separated by a colon. In the event that we have multiple arguments, we can separate the `argument: type` pairs with commas.

In actuality, the process of obtaining data in an application is frequently asynchronous. To accurately reflect this in our typing, we can utilize the `Promise` type in conjunction with the `ICar` type, and prefix the function with the `async` keyword. This approach ensures that our function's return type accurately reflects the asynchronous nature of data retrieval:

```ts
const getCar = async (id: string): Promise<ICar> => {/* fetch data */}

// OR
// async function getCar(id: string): Promise<ICar> {/* fetch data */}
```

At times, when retrieving data, particularly from content management systems, we may not have complete control over the returned information. In these cases, the data we desire may be wrapped with additional, yet useful, information provided by the CMS, such as a block UUID. The returned data may look something like this:

```ts
{
  _uuid: "123",
  data: { /* primary data */ }
}
```

In situations such as this, we can utilise generics to wrap our existing types, and specify which property should be typed as the wrapped type. For instance, using the aforementioned example of a `_uuid` and `data` key-value pairs, we could create an `ICMSResponse` interface. This approach allows us to maintain the useful information provided by the CMS while clearly defining the expected data structure of our application:

```ts
interface ICMSResponse<T> {
  _uuid: string,
  data: T,
}
```

The `T` in `ICMSResponse<T>` represents a passed type, indicating that the data property will be typed according to the type passed to `ICMSResponse`. This allows for flexibility and dynamic typing, while maintaining a clear and consistent structure.

If we were expecting an `ICMSResponse` from our earlier `getCar` function, we could update the function's types to represent that:

```ts
export const getCar = async (id: string): Promise<ICMSResponse<ICar>> => { /* cms data */ }
```

## Conclusion

I hope that this article has assisted you in gaining a deeper understanding of how to effectively utilise TypeScript in various common scenarios. By providing examples and practical explanations, I aim to empower you to confidently incorporate TypeScript in your projects for improved code reliability and readability.

If you’ve found this article useful, please follow me on [Medium](https://medium.com/@wearethreebears), Dev.to and/ or [Twitter](https://twitter.com/wearethreebears).