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:
- number: for numeric values (e.g., 1, 3.14, -100)
- string: for text values (e.g., "hello", "world")
- boolean: for true/false values
- null and undefined: for empty or non-existent values
- 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;
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
In addition, TypeScript provides some special types, like any
and unknown
.
-
any:
any
can be assigned to any variable and it will acceptany
value andany
type, -
unknown:
unknown
is likeany
, 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;
}
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;
}
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,
}
You can also explicitly set the values of enum members:
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.
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.
interface ICar = {
owner: string;
milage: number;
brand: Brand;
}
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:
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:
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.
enum EColor {
"red" = "#ff0000",
"blue" = "#0000ff",
}
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"]
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"
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:
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:
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:
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:
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:
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:
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:
type Person = typeof people[number];
Which we can now add to our ICar
interface:
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:
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:
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
:
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:
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:
type Owner = IPerson | IGarage;
Which will allow us to update ICar
:
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:
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
:
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:
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:
type BrandAccentMap = {
[key in Brand]: string;
}
We can now type brandAccents
with BrandAccentMap
:
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
:
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
:
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
:
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:
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:
{
_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:
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:
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, Dev.to and/ or Twitter.
Top comments (4)
If you learn how to code in C# , TypeScript will be easier for you.
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 ๐
that was good
That is cool!๐