TypeScript Advanced Tips and Tricks

June 7, 2024 (2d ago)

Introduction

Creating effective types in TypeScript can be a challenge for many JavaScript developers. Some developers fall back on using the ubiquitous any type, while others use overly broad types that lack precision.

It's important to recognize that strong typing can significantly reduce cognitive load and minimize the time spent verifying implementations. In Functional Programming, the clarity of function definitions serves a similar purpose. Your types should be trustworthy and precisely define the structure of your data.

Today, we'll explore some tips on utilizing utility types and address various scenarios that can aid you in your day-to-day development.

Pick and Omit

These two utility types are built into TypeScript and can be invaluable for avoiding repetitive interface definitions. They enable us to reuse common structures without the need for constant rewriting.

Let's illustrate this with a practical example. Suppose we're creating a store for use in various components, defined as follows:

interface Stores {
  user: User,
  theme: Theme
}

Instead of duplicating these types in every component where they're used, such as with an AvatarProps interface:

interface AvatarProps {
  user: User,
  rounded: boolean 
}

We can utilize the utility types to streamline this process and reduce potential errors like adding an extra type for the user prop:

interface AvatarProps extends Pick<Stores, "user"> {
  rounded: boolean 
}

The Pick utility type creates a new type containing only the specified keys from the original type. Think of it as a function with two parameters: the first being the complete type and the second being a union of the keys to "pick". In this case, we use a fixed string union to match each desired key.

For example, given the type:

interface Foo {
  key1: number,
  key2: number,
  key3: number
}

type FooPicked = Pick<Foo, "key1" | "key2">

The resulting type FooPicked would be:

interface FooPicked {
  key1: number,
  key2: number
}

On the other hand, the Omit utility type works inversely. It excludes keys that match the specified union, as shown in this example:

interface Foo {
  key1: number,
  key2: number,
  key3: number
}

type FooOmitted = Omit<Foo, "key1" | "key2">

The resulting type FooOmitted would be:

interface FooOmitted {
  key3: number
}

These utility types can greatly enhance code readability, maintainability, and reduce the chances of introducing bugs by avoiding redundant type declarations.

Partial

Let's delve into the concept of using Partial in TypeScript, particularly in the context of managing state updates within a store, such as in a React application.

Consider a scenario where we have a state object:

interface State {
  foo: string;
  bar: string;
}

const initialState: State = {
  foo: "foo",
  bar: "bar"
};

Now, when performing a state update, say with the setState method in a class-based React component:

const updateState: SetState = (value: Partial<State>) => ({
  ...initialState,
  ...value
});

The Partial type allows us to specify that only a subset of the state properties needs to be updated. It's essentially adding the ? (optional) modifier to each first-level property of the interface.

interface PartialState {
  foo?: string;
  bar?: string;
}

It's important to note that Partial affects only first-level properties; nested objects' properties remain unaffected. This utility type proves handy when dealing with partial updates to complex state structures.

readonly

If you prefer working with immutable data, TypeScript offers a helpful keyword called readonly. This feature allows you to specify which properties of your object can or cannot be modified.

In the context of stores, especially when using the Flux architecture where state modifications are discouraged, marking properties as readonly ensures that attempts to modify them will result in compile-time errors.

interface Stores {
  readonly user: User;
  readonly theme: Theme;
}

You can also utilize the Readonly utility type to achieve the same effect:

type ReadonlyStores = Readonly<Stores>;

When attempting to modify a readonly property, TypeScript will catch and report the error during compilation:

const store: ReadonlyStores = {
  user: new User(),
  theme: new Theme(),
};

store.user = new User(); // Error: Cannot assign to 'user' because it is a read-only property.

It's essential to note that while TypeScript enforces readonly properties at compile-time, runtime enforcement depends on TypeScript's tracking. If there are parts of your codebase that TypeScript doesn't track or if TypeScript rules are bypassed, modifications to readonly properties can still occur during runtime. Therefore, it's crucial to adhere to TypeScript's guidelines consistently to maintain the immutability of your data.

Infer

TypeScript's inference capability is quite robust. It can automatically deduce the type of a variable based on its initialization, saving us from explicitly specifying types in many cases.

For instance, consider this code snippet:

let a = "a"; // TypeScript infers 'a' as a string
a = 3; // This will result in an error

You only need to specify a type explicitly when the variable is declared without an initialization value:

let a: string;
a = "a";

If no value is assigned upon declaration, TypeScript defaults to any, though some configurations can prevent this automatic any typing:

let a; // TypeScript defaults to 'any' (unless configured otherwise)
a = "a";
a = 3; // No error raised

We can leverage this inference power in defining types for objects like stores. Instead of manually crafting an interface:

interface Stores {
  user: User;
  theme: Theme;
}

const stores: Stores = {
  user: new User(),
  theme: new Theme(),
};

We can let TypeScript automatically derive the type from the object's structure:

const stores = {
  user: new User(),
  theme: new Theme(),
};
type Stores = typeof stores;

Using typeof in this context extracts the type inferred by TypeScript from the object stores, resulting in the same type as the manually defined Stores interface. This approach is beneficial because any additions or modifications to the object are automatically reflected in the type, reducing the chance of errors compared to manually updating an interface.

I find this feature particularly handy as it keeps the type declaration closely tied to the actual object structure, ensuring consistency and reducing manual effort in maintaining type definitions.

Conclusion

TypeScript indeed offers a layer of security and structure to developers, making code more robust and error-resistant. However, it's crucial to understand that TypeScript's benefits primarily manifest during development, providing a safety net and improved code readability. Once transpiled to JavaScript, the generated code doesn't strictly adhere to TypeScript's rules, potentially allowing modifications to readonly properties or access to private attributes.

In essence, TypeScript acts as a guide and enforcer during development, enhancing code quality and catching errors early. However, developers must remain aware that the generated JavaScript output may not enforce TypeScript's constraints at runtime, requiring a balance of TypeScript's benefits and awareness of JavaScript's runtime behavior.

That's all! Thank you for reading this blog post. Happy coding! 👨‍💻👩‍💻