6 TypeScript Code Patterns To Make Your Code More Robust
Simple and practical solutions you can apply in daily work
I found out that I repeatedly use some TypeScript code patterns in daily work. Those code patterns aren’t heavy or complex, and they also don’t require a lot of boilerplate code. They’re just simple and practical solutions to resolve a few common problems.
In the next few sections, I’ll share my six favorite TypeScript code patterns.
Use Pick To Implement Interface Segregation Principle
The interface segregation principle is defined as:
Clients should not be forced to implement interfaces they do not use.
Let’s say we have a Client type, it’s used in multiple places. Sometimes only a subset of properties is used. According to the interface segregation principle, the function parameter with the type should be the minimal type required.
We can use Pick utility type to exclude unused properties from the Client type. As shown in the code snippet below, only the name and active properties are picked. Thus the ClientSummary type represents a smaller, more specific interface that only includes the properties that it needs.
interface Client {
name: string;
dateOfBirth: Date;
active: boolean;
}
type ClientSummary = Pick;
const clients: ClientSummary = {
name: 'John',
active: true,
};
An alternative way is to use Exclude utility type as below:
type ClientSummary = Exclude<Client, 'dateOfBirth'>;
However, Pick is more robust because no changes are required when additional properties are added to the Client type.
Const Assertion To Derive Types From Literal Expressions
as const is a TypeScript construct for literal values called const assertion . When as const is applied, we get a const object with every property as a non-widen literal type. We can use it to derive types from the declared constants with const assertion.
The following code snippet is an example that I discussed in another article . In a nutshell, we derived payGradeType and payValueType from the payGrades constant. These types form a constraint-type system. When the source constant is modified, all related types will be automatically updated.
export const payGrades = {
low: "1",
average: "2",
high: "3"
} as const;
type t = typeof payGrades;
type payGradeType = keyof t; // 'low' | 'average' | 'high'
type payValueType = t[keyof t]; // '1' | '2' | '3'
const hisPay: payValueType = '3'; //okay
const myPay: payValueType = '4'; // error
By using const assertions to derive types from literal expressions, you can create variables with more specific and accurate types, which can help to improve the type-safety and correctness of your Type.
Exhaustive Checks With “never” Type
The never type represents the type of values that never occur. It can be useful for performing exhaustive checks, as you can use the never type to ensure that all possible cases are handled in a switch statement or other control flow construct.
A good application of never type is as the type guard for impossible types.
The following example shows how to cover all possible literal type values. Since the DataTypes includes only the two literal types client and order, assertUnreachable will never be reached.
If another developer adds a new literal type into the DataTypes, and forgets to update the switch statement, a compile-time error will be thrown.
type DataTypes = 'client' | 'order';
function getProcessName(c: DataTypes): string {
switch(c) {
case 'client':
return 'register' + c;
case 'order':
return 'process' + c;
default:
return assertUnreachable(c);
}
}
function assertUnreachable(x: never): never {
throw new Error("something is very wrong");
}
With the exhaustive type checking in place, we can detect a missing condition at compile time instead of run time.
Use Opaque Type To Simulate Nominal Typing Behavior
TypeScript is a structural type system. In the structure type system , two types with the same shape are compatible. This makes TypeScript very flexible, but it can also create potential issues.
In the contrived example below, we have two compatible types Customer and VIPCustomer. The function getVIPName should only take the VIPCustomer type argument. But, if the Customer type argument is passed in by mistake, it won’t error out due to the structure typing limitation.
type Customer {
name: string
}
type VIPCustomer {
name: string
}
const cust = {name: 'John'} as Customer;
const vip = {name: 'Mark'} as VIPCustomer;
function getVIPName(vip: VIPCustomer) {
return vip.name;
}
console.log('vip name:', getVIPName(vip)); //vip name: Mark
// The getVIPName works without error
console.log('vip name:', getVIPName(cust)); //vip name: John
The above problem can be resolved by opaque type. Opaque types allow you to create types that are nominally typed, but are still compatible with structural types.
Although TypeScript doesn’t support opaque type out-of-box, we can achieve similar behavior using intersection type.
The below OpaqueType makes use of Generic Type and intersection type. Now the type Customer and VIPCustomer are structurally different. As the internal _brand property only exists in compile-time, there is no run-time cost associated with it.
type OpaqueType = K & { _brand: T }
type Customer = OpaqueType
type VIPCustomer = OpaqueType
function getVIPName(vip: VIPCustomer) {
return vip.name;
}
const cust = {name: 'John'} as Customer;
const vip = {name: 'Mark'} as VIPCustomer;
console.log('vip name:', getVIPName(vip)); //vip name: Mark
// Error: Argument of type 'Customer' is not assignable to parameter of type 'VIPCustomer'.
console.log('vip name:', getVIPName(cust));
The opaque type implementation can make your TypeScript code cleaner and stronger when used in the right place.
Lookup property type from an Object Type
TypeScript is about types . Often, we need to extract an existing object property type from a complex object type.
We use conditional types and never to filter out the required data type definitions in lookup type definition below.
type PropertyType =
Path extends keyof T ? T[Path] :
Path extends `${infer K}.${infer R}` ? K extends keyof T ? PropertyType :
never :
never;
type lookup = Key extends keyof T? PropertyType: never;
The lookup type may look confusing at the first glance. Let’s break it down.
Firstly, to access a property type, we’ll create a new type with recursive type aliases.
When Path extends keyof T is truthy, it means the full path is matched. Thus, we return the current property type.
When Path extends keyof T is falsy, we use the infer keyword to build a pattern to match the Path. If it matches, we make a recursive call to the next-level property. Otherwise, it will return a never and that means the Path does not match with the type.
If it does not match, continue recursively with the current property as the first parameter.
The generated property type can be used in other functions to enforce type safety.
Refactor Excessive Function Parameters With Destructing Assignments
A common issue for TypeScript code is excessive function parameters. We can use destructing assignments to refactor excessive function parameters and make your code more concise and readable.
Destructing assignments allow you to unpack the values of an object or array into individual variables. This can be useful when you have a function with multiple parameters that are related, as you can use destructing assignments to unpack the values into individual variables and pass them to the function as separate arguments.
The example below illustrates the issue of excessive parameters. For example, if a new parameter type, let’s say middleName is required. We need to be very careful in adding parameters to the right place.
Obviously, it’s hard to maintain. It’s also hard to read the caller function to match the argument with its name.
interface Staff {
firstName: string,
lastName: string,
age: number,
contact: string,
isActive: boolean,
isDelete: boolean
}
function register(firstName: string, lastName: string, age: number, contact: string, isActive: boolean, isDelete: boolean){
console.log(`${firstName} ${lastName} ${age} ${contact} ${isActive} ${isDelete}`)
}
register('john', 'duggan', 25, '123456', true, false);
To refactor it, we make use of object destructuring and object literals as below. It’s one of my favorite methods as it’s simple. concise and readable.
TypeScript is intelligent. It not only assigns the value to the corresponding variable but also infers the type correctly.
// use destructing and pass in an object
function register2({firstName, lastName, age, contact, isActive, isDelete}: Staff){
console.log(`${firstName} ${lastName} ${age} ${contact} ${isActive} ${isDelete}`)
}
const user = {
firstName: 'john',
lastName: 'duggan',
age: 25,
contact: '1234567',
isActive: true,
isDelete: false
};
register2(user);
After the refactoring, the sequence of parameters doesn’t matter anymore. You can add or remove a property without worrying about its position.
The TypeScript typing system is amazing. The challenge for developers is to make full use of its powerful toolbox for clean and strongly typed code.
I hope this article is useful to you. If you have your favorite TypeScript code patterns, do let me know.
If you like this article, you may also like to check out my other article about TypeScript best practices.
- Title: 6 TypeScript Code Patterns To Make Your Code More Robust
- Author: Sunny Sun
- Created at : 2021-11-06 00:00:00
- Updated at : 2024-08-16 19:46:17
- Link: http://coffeethinkcode.com/2021/11/06/6-typescript-code-patterns-to-make-your-code-more-robust/
- License: This work is licensed under CC BY-NC-SA 4.0.