Mastering TypeScript: A Comprehensive Guide to Enhancing Your Code
Posted on Jul 14
TypeScript is swiftly becoming a go-to language for developers who want to write robust, maintainable JavaScript code. However, to truly master TypeScript, you need to delve into its advanced features. This guide will provide you with a plethora of tips to enhance your TypeScript code, from understanding set theory to mastering enums, generics, and beyond. To follow along with the code examples provided in this guide, you can use the official TypeScript playground.
1. Embrace the Power of Sets
In set theory, a set is a collection of unique elements that share common properties. This concept can be applied to TypeScript, where a type is a set of possible values that a variable can hold. For example, the type number
is a set of all possible numbers. Understanding set theory can help you better infer the behavior of types in TypeScript.
let x: number; // a set of all possible numbers
x = 123; // 123 is a possible number
x = 'Hello'; // will throw an error because hello is a string, not a number
TypeScript uses two operators as type operators: the |
operator for union and the &
operator for intersection. However, they behave counter-intuitively when used to combine types (interfaces). The |
operator, known as a union type operator, creates a type that includes the intersection of properties and methods shared by the constituent types. On the other hand, the &
operator, representing an intersection type, combines the features of multiple types.
type User = {
getId(): number;
getName(): string;
}
type Admin = {
getId(): number;
getUserName(): string;
}
declare function Person(): User | Admin;
const person = Person()
person.getId() // succeeds
person.getName() //fails
person.getUsername() // fails
2. Use Discriminated Unions Over Optional Fields
When dealing with different types of responses, such as successful data retrieval or error notifications, you might typically use optional fields or conditional type checks. However, with discriminated unions, you can create a relationship between the interfaces, providing a more elegant and type-safe solution.
interface APIResponse {
success: true | false;
data?: number;
error?: string;
}
function handleAPIResponse(response: APIResponse) {
return (response.success) ? response.data : response.error
}
// Using Discriminated Unions
interface SuccessResponse {
success: true;
data: number;
}
interface ErrorResponse {
success: false;
error: string;
}
type APIResponse = SuccessResponse | ErrorResponse;
function handleAPIResponse(response: APIResponse) {
return (response.success) ? response.data: response.error
}
3. Narrow Down Types with Type Predicates
Type predicates allow you to define a function that returns a boolean type to check if a value is of a particular type. They are often defined in the form of variable is type
and are used on the return type of functions. This helps the compiler to make precise type inferences, making debugging processes faster.
type Fish = {
swim:() => void
}
type Bird = {
fly:() => void
}
function isFish( pet: Fish | Bird ): pet is Fish {
return (pet as Fish).swim !== undefined
}
function getFood(pet: Fish | Bird) {
if(isFish(pet)) {
pet // if true, pet is of type Fish
return 'fish food'
}
else {
pet //if false, pet is of type Bird
return 'bird food'
}
}
4. Organize Your Code with Enums
Enums in TypeScript allow you to write a set of named constants. They define related values as numbers or strings. Since they allow grouping related values, it is a very useful way to organize your code, which aids in type safety.
enum Color {
Red,
Green,
Blue
}
// usage
let color = Color.Red;
5. Boost Code Flexibility with Generics
Generics in TypeScript enable the development of reusable, type-safe components that can handle a wide range of data types. They increase the flexibility of programming by enabling the development of functions, classes, and interfaces that can operate on any data type as long as it complies with specific requirements.
function func<T>(value: T):T {
return value;
}
const result = func('Hello John');
6. Control How Union Types are Distributed
Type inference is TypeScript’s instinct; most of the time, it works silently for you. However, you may need to intervene in subtle cases of ambiguities. Distributive conditional types is one of these cases. By default, when TypeScript encounters a union type for a generic parameter, it distributes into each constituent. This behavior can be altered by using a special syntax and wrapping the generic parameter in a pair of []
.
type ToArray<T> = T extends Array<unknown> ? T: T[];
type Foo = ToArray<string|number>; // string[] | number[]
type ToArray<T> = [T] extends [Array<unknown>] ? T : T[];
type Foo = ToArray<string | number>; // (string | number)[]
7. Use Exhaustive Checking to Catch Unhandled Cases at Compile Time
When switch-casing over an enum, it’s a good habit to actively err for the cases that are not expected instead of ignoring them silently. With TypeScript, you can let static type checking find the error earlier for you by utilizing the never
type.
enum Weekdays {
Sunday = 1,
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday
}
function getDay(day: Weekdays) {
switch (day) {
case Weekdays.Sunday:
return 'Sunday';
case Weekdays.Monday:
return 'Monday';
// ... other cases
default:
const _exhaustiveCheck: never = day;
return _exhaustiveCheck;
}
}
8. Prefer type
Over interface
In TypeScript, type
and interface
are very similar constructs when used for typing objects. However, using the more versatile type
construct results in more consistent code.
type Person = {
name: string;
age: number;
};
// vs
interface Person {
name: string;
age: number;
}
9. Prefer Tuple Over Array Whenever Appropriate
Tuples in TypeScript are a type of data structure that can hold elements of different types. They are especially useful when you want to create an array with a fixed number of elements, where each element has a specific type.
let tuple: [string, number, boolean] = ['hello', 42, true];
10. Use Deep Partials for Mocking Entities
Deep partials can be used to create mock entities for testing purposes. They allow you to create an object that partially implements an interface, making it easier to test specific parts of your code.
type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]>;
};
interface Foo {
bar: {
baz: number;
};
}
const mockFoo: DeepPartial<Foo
> = {
bar: {
baz: 42,
},
};
11. Use Assertion Functions Inside Classes
Assertion functions can be used inside classes to ensure that a certain condition is true. If the condition is not met, an error is thrown. This can be useful for validating inputs or ensuring that an object is in a valid state.
class MyClass {
private _value: number;
constructor(value: number) {
this._value = value;
}
setValue(value: number) {
assert(value >= 0, 'Value must be positive');
this._value = value;
}
}
function assert(condition: any, msg?: string): asserts condition {
if (!condition) {
throw new Error(msg);
}
}
In conclusion, TypeScript is a powerful tool for writing scalable and maintainable code. By incorporating type annotations, union types, type predicates, enums, and generics, you can greatly improve your code’s type safety and flexibility. These tips only scratch the surface of TypeScript’s features and advantages. You are urged to learn more about the language and find new ways to improve your code. Because of TypeScript’s robust tooling, tight typing, and support for contemporary JavaScript code features, you can produce better code more quickly.