In this post, I am going to go over a few TypeScript features that will make your code cleaner and strongly typed. You might already be familiar with some of these features, and some you might not. Admittedly, I am still finding new things to learn in TypeScript, so skip over the items you’re already familiar with.
1. Type Aliases
Type aliases help improve code readability by assigning custom names to existing types. They can be particularly useful when dealing with complex types or frequently used throughout your code. By creating a type alias, you can reduce the complexity of your code and make it easier to understand the intent behind a particular type.
In this example, we define two type aliases, UserID
and UserName
, to represent simple number
and string
types. This makes it clear that the getUserInfo
function expects specific arguments, increasing the code’s readability.
type UserID = number; type UserName = string; function getUserInfo(id: UserID, name: UserName) { // Your implementation here }
2. Type Assertions
Type assertions allow you to manually specify the type of a value, overriding TypeScript’s type inference. This can be useful when you’re confident about the type of a value, but TypeScript cannot infer it correctly. Remember that type assertions should be used cautiously, as incorrect assertions can lead to runtime errors.
interface User { id: number; name: string; } const jsonString = '{"id": 1, "name": "John Doe"}'; // Using 'as' keyword for type assertion const userData1 = JSON.parse(jsonString) as User; // Using angle-bracket syntax for type assertion (not recommended in JSX) const userData2: User = <User>JSON.parse(jsonString);
3. ReturnType
The ReturnType
utility type provides a way to obtain the return type of a function, making it easier to reuse types and improve type safety. This can be particularly useful when working with higher-order functions or when mocking functions for testing.
function createUser(id: number, name: string): User { return { id, name }; } type UserReturnType = ReturnType<typeof createUser>; // This function accepts a callback that should return the same type as 'createUser' function fetchUser(callback: () => UserReturnType): UserReturnType { const userData = callback(); return userData; } const user = fetchUser(() => createUser(1, 'John Doe'));
4. Mapped Types
Mapped types allow you to create new types based on existing ones, which can help you avoid duplicating code and increase maintainability. By iterating over the keys of an existing type, you can transform the type in various ways, such as making all properties readonly, optional, or even changing their types.
In the example below, we create a Readonly
mapped type that iterates over the keys of a given type T
and makes all its properties readonly. We then create a readOnlyUser
object based on the User
interface, ensuring all its properties are read-only.
type Readonly<T> = { readonly [P in keyof T]: T[P]; }; interface User { id: number; name: string; } const readOnlyUser: Readonly<User> = { id: 1, name: 'John Doe' }; readOnlyUser.id = 2; // Error: Cannot assign to 'id' because it is a read-only property
5. Enums
Enums provide a way to define a set of named constants, improving code readability and reducing the chance of errors. By using enums, you can group related values together and ensure that a variable can only take one of a predefined set of values. This helps you catch bugs early, as TypeScript will throw an error if an invalid value is assigned to a variable with an enum type.
In this example, we define a UserRole
enum with three possible values: Admin
, User
, and Guest
. The greetUser
function expects a UserRole
type, ensuring that only valid role values can be passed to the function.
enum UserRole { Admin = 'Admin', User = 'User', Guest = 'Guest' } function greetUser(role: UserRole) { // Your implementation here }
6. Tuple Types
Tuples allow you to represent a fixed-length array with elements of different types, making your code more expressive and less error-prone. Using tuples, you can ensure that a specific set of values is present in the correct order and with the right types, which can help catch bugs early and make your code more maintainable.
In this example, we define a UserTuple
type with three elements: a number
, a string
, and a UserRole
. We then create a user
variable based on this tuple type, ensuring that the values are in the correct order and have the right types.
type UserTuple = [number, string, UserRole]; const user: UserTuple = [1, 'John Doe', UserRole.Admin];
7. Type Guards
Type guards help narrow the variable type within a conditional block, making your code safer and more readable. By using type guards, you can avoid runtime errors caused by incorrect assumptions about a variable’s type and make it easier to understand the flow of your code.
In this example, we define two interfaces, Cat
and Dog
, along with a union type Animal
that can be either a Cat
or a Dog
. The makeNoise
function accepts an Animal
type and uses a type guard to determine the specific type of the animal.
If the animal.type
property is 'cat'
, TypeScript understands that the animal
variable is of type Cat
within the conditional block, and we can safely call the meow
method. Similarly, if the type is not 'cat'
, TypeScript infers that the animal
variable is of type Dog
, and we can safely call the bark
method.
interface Cat { type: 'cat'; meow(): void; } interface Dog { type: 'dog'; bark(): void; } type Animal = Cat | Dog; function makeNoise(animal: Animal) { if (animal.type === 'cat') { animal.meow(); // TypeScript knows that 'animal' is of type 'Cat' here } else { animal.bark(); // TypeScript knows that 'animal' is of type 'Dog' here } }
8. Utility Types
Utility types, such as Partial
, Pick
, and Omit
, provide flexible ways to transform and manipulate types without duplicating code. They can help you create new types based on existing ones while modifying specific properties or behaviours. This allows you to keep your code DRY and maintainable, as you can reuse and adapt types easily.
In the following example, we define a User
interface with three properties: id
, name
, and age
. We then use the Omit
utility type to create a new type called UserWithoutAge
, which is identical to the User
type but without the age
property. This can be particularly useful when creating a function that expects a user object without an age property, ensuring type safety.
interface User { id: number; name: string; age: number; } type UserWithoutAge = Omit<User, 'age'>; const user:UserWithoutAge = { id: 1, name: 'John Doe' };
9. Indexed Access Types
Indexed access types allow you to look up a specific property type from another type, making it easier to extract and reuse types in your code. This can be particularly useful when you want to create a function that only needs a specific property from an object but still wants to maintain type safety.
In the following example, the User
interface has three properties: id
, name
, and age
. We use an indexed access type to extract the type of the id
property and create a new type called UserIdType
.
interface User { id: number; name: string; age: number; } type UserIdType = User['id']; // Equivalent to 'number' function getUserId(user: User): UserIdType { return user.id; }
By using indexed access types, we can keep our code DRY and avoid manually specifying the type of the id
property, which can reduce the risk of errors if the type changes in the future.
10. Conditional Types
Conditional types enable you to express a type relationship based on conditions, providing a powerful way to create more dynamic and flexible types. With conditional types, you can choose a type based on whether a specific condition is met, allowing you to create complex types that adapt to different scenarios.
The example below defines a Flatten
type that checks if the input type T
is an array. If it is, it “flattens” the array and returns the type of its elements; otherwise, it returns the input type as-is.
type Flatten<T> = T extends any[] ? T[number] : T; type Test1 = Flatten<number[]>; // Equivalent to 'number' type Test2 = Flatten<string>; // Equivalent to 'string' function flattenArray<T>(input: T): Flatten<T>[] { return Array.isArray(input) ? (input as any).flat() : [input]; } const flattenedNumbers = flattenArray([1, 2, [3, 4]]); const flattenedString = flattenArray('hello');
Conclusion
By leveraging these TypeScript features, you can write cleaner, safer, and more maintainable code that will be easier to understand for you and your fellow developers.
Good suggestions, although as an OO developer I am not a fan of that Omit approach.