DEV Community

Cover image for Understanding and Utilising TypeScript, by example
Liam Hall
Liam Hall

Posted on • Updated on • Originally published at Medium

Understanding and Utilising TypeScript, by example

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:

let myName: string = "John";
let myAge: number = 30;
let isStudent: boolean = false;
Enter fullscreen mode Exit fullscreen mode

Also, typeof operator in TypeScript returns the type of a variable, which can be one of these primitive types.

console.log(typeof "hello");  // Output: string
console.log(typeof 100);     // Output: number
console.log(typeof true);    // Output: boolean
Enter fullscreen mode Exit fullscreen mode

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
type Brand = "audi" | "ford"; // Value must be "audi" or "ford"

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

Enter fullscreen mode Exit fullscreen mode

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
interface ICar = {
  owner: string;
  milage: number;
  brand: Brand;
}

Enter fullscreen mode Exit fullscreen mode

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

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.

enum EFuel {
  petrol,
  diesel,
}
Enter fullscreen mode Exit fullscreen mode

You can also explicitly set the values of enum members:

enum EColor {
  "red" = "#ff0000",
  "blue" = "#0000ff",
}
Enter fullscreen mode Exit fullscreen mode

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.

const car: ICar = {/* values */} // Single car
const cars: ICar[] = {/* values */} // Multiple cars
Enter fullscreen mode Exit fullscreen mode

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.

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

Enter fullscreen mode Exit fullscreen mode
enum EColor {
  "red" = "#ff0000",
  "blue" = "#0000ff",
}
Enter fullscreen mode Exit fullscreen mode

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:

export interface ICar {
  owner: string;
  milage: number;
  fuel: string;
  color: string;
  brand: string;
  radioCode: string;
}
Enter fullscreen mode Exit fullscreen mode

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:

enum EFuel {
  petrol,
  diesel,
}

enum EBrand {
  audi,
  ford,
}
Enter fullscreen mode Exit fullscreen mode

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.

enum EColor {
  "red" = "#ff0000",
  "blue" = "#0000ff",
}
Enter fullscreen mode Exit fullscreen mode

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

const fuel = EFuel.petrol; // Or EFuel["petrol"]
const brand = EBrand.audi; // Or EBrand["audi"]
const color = EColor.red; // Or EFuel["red"]
Enter fullscreen mode Exit fullscreen mode

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.:

type Fuel = keyof typeof EFuel; // "petrol" | "diesel"
type Brand = keyof typeof EBrand; // "audi" | "ford"
type Color = keyof typeof EColor; // "red" | "blue"
Enter fullscreen mode Exit fullscreen mode
export interface ICar {
  owner: string;
  milage: number;
  fuel: Fuel;
  color: Color;
  brand: Brand;
  radioCode: string;
}
Enter fullscreen mode Exit fullscreen mode

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:

const radioCodes = {
    sony: "1111",
    pioneer: "2222"
};
Enter fullscreen mode Exit fullscreen mode

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

type RadioCode = keyof typeof radioCodes; // "sony" | "pioneer"
Enter fullscreen mode Exit fullscreen mode

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

export interface ICar {
  owner: string;
  milage: number;
  fuel: Fuel;
  color: Color;
  brand: Brand;
  radioCode: RadioCode;
}
Enter fullscreen mode Exit fullscreen mode

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:

export interface ICar {
  owner: string;
  milage: number;
  fuel: Fuel;
  color: Color;
  brand: Brand;
  radioCode?: RadioCode;
}
Enter fullscreen mode Exit fullscreen mode

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:

const people = ["Liam Hall", "Steve Jobs", "Ozzy Osborne", "Robert De Niro"];
Enter fullscreen mode Exit fullscreen mode

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:

const people: string[] = ["Liam Hall", "Steve Jobs", "Ozzy Osborne", "Robert De Niro"];
Enter fullscreen mode Exit fullscreen mode

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:

type Person = typeof people[number];
Enter fullscreen mode Exit fullscreen mode

Which we can now add to our ICar interface:

export interface ICar {
  owner: Person;
  milage: number;
  fuel: Fuel;
  color: Color;
  brand: Brand;
  radioCode?: RadioCode;
}
Enter fullscreen mode Exit fullscreen mode

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:

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

export interface IGarage {
  name: string;
  companyNumber: number
}
Enter fullscreen mode Exit fullscreen mode

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

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}
]
Enter fullscreen mode Exit fullscreen mode

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

export interface ICar {
  owner: IPerson | IGarage;
  milage: number;
  fuel: Fuel;
  color: Color;
  brand: Brand;
  radioCode?: RadioCode;
}
Enter fullscreen mode Exit fullscreen mode

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

const owners: (IPerson | IGarage)[] = [...people, ...garages];
Enter fullscreen mode Exit fullscreen mode

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:

type Owner = IPerson | IGarage;
Enter fullscreen mode Exit fullscreen mode

Which will allow us to update ICar:

export interface ICar {
  owner: Owner;
  milage: number;
  fuel: Fuel;
  color: Color;
  brand: Brand;
  radioCode?: RadioCode;
}
Enter fullscreen mode Exit fullscreen mode

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:

const lastSale: [IGarage, IPerson] = [
  {name: "London Garage", companyNumber: 1111},
  {firstName: "Liam", lastName: "Hall"}
];
Enter fullscreen mode Exit fullscreen mode

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:

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

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:

const brandAccents = {
  audi: "#000000",
  ford: "#333333"
}
Enter fullscreen mode Exit fullscreen mode

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:

type BrandAccentMap = {
  [key in Brand]: string;
}
Enter fullscreen mode Exit fullscreen mode

We can now type brandAccents with BrandAccentMap:

const brandAccents: BrandAccentMap = {
  audi: "#000000",
  ford: "333333"
}
Enter fullscreen mode Exit fullscreen mode

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:

type BrandAccentMap = {
  [key in Brand]?: string;
}
Enter fullscreen mode Exit fullscreen mode

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:

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

// OR
// function getCar(id) {/* fetch data */}
Enter fullscreen mode Exit fullscreen mode

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:

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

// OR
// function getCar(id: string): ICar {/* fetch data */}
Enter fullscreen mode Exit fullscreen mode

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:

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

// OR
// async function getCar(id: string): Promise<ICar> {/* fetch data */}
Enter fullscreen mode Exit fullscreen mode

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:

{
  _uuid: "123",
  data: { /* primary data */ }
}
Enter fullscreen mode Exit fullscreen mode

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:

interface ICMSResponse<T> {
  _uuid: string,
  data: T,
}
Enter fullscreen mode Exit fullscreen mode

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:

export const getCar = async (id: string): Promise<ICMSResponse<ICar>> => { /* cms data */ }
Enter fullscreen mode Exit fullscreen mode

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, Dev.to and/ or Twitter.

Latest comments (4)

Collapse
 
matin192hp profile image
matin HP

that was good

Collapse
 
science_and_fun profile image
Science โˆž Fun๐Ÿ›ฐ๐ŸŒ ๐Ÿ•ณ๐Ÿš€๐Ÿค–๐Ÿงชโš—๐Ÿ“โš™๐Ÿงฌ๐Ÿ˜ƒ

If you learn how to code in C# , TypeScript will be easier for you.

Collapse
 
wearethreebears profile image
Liam Hall

Appreciate your comment, I'm sure it would be but I'm a Senior Engineer, primarily working in front end and have worked with Typescript for the past 4 years so I'm not sure learning C# would be good use of my time ๐Ÿ˜…

Collapse
 
science_and_fun profile image
Science โˆž Fun๐Ÿ›ฐ๐ŸŒ ๐Ÿ•ณ๐Ÿš€๐Ÿค–๐Ÿงชโš—๐Ÿ“โš™๐Ÿงฌ๐Ÿ˜ƒ

That is cool!๐Ÿ˜€