DEV Community

Cover image for Mastering TypeScript: Advanced Type Tricks for Bulletproof Code
Aarav Joshi
Aarav Joshi

Posted on

Mastering TypeScript: Advanced Type Tricks for Bulletproof Code

TypeScript's type system is a powerhouse, offering developers tools to create robust and maintainable code. Let's explore some advanced type manipulation techniques that can take your TypeScript skills to the next level.

Conditional types are a game-changer. They allow us to create types that depend on other types. Think of them as if-else statements for types. Here's a simple example:

type IsString<T> = T extends string ? true : false;

type Result1 = IsString<"hello">; // true
type Result2 = IsString<42>; // false
Enter fullscreen mode Exit fullscreen mode

This might seem basic, but it's the foundation for more complex type manipulations. We can use conditional types to create utility types that adapt based on input.

Mapped types are another powerful feature. They let us transform existing types into new ones. Imagine you have a type representing a user, and you want to make all its properties optional:

type User = {
  name: string;
  age: number;
  email: string;
};

type PartialUser = {
  [K in keyof User]?: User[K];
};
Enter fullscreen mode Exit fullscreen mode

Now, PartialUser has the same properties as User, but they're all optional. This is just scratching the surface of what mapped types can do.

Template literal types are a newer addition to TypeScript. They allow us to manipulate string literals at the type level. Here's a cool example:

type Greeting = `Hello, ${string}!`;

let greeting: Greeting = "Hello, TypeScript!"; // OK
let invalid: Greeting = "Goodbye, TypeScript!"; // Error
Enter fullscreen mode Exit fullscreen mode

This ensures that our greeting always starts with "Hello, " and ends with "!". It's a simple example, but template literal types can be used for much more complex string manipulations.

The infer keyword is a powerful tool in conditional types. It allows us to extract type information from other types. Let's look at an example:

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

function greet(name: string): string {
  return `Hello, ${name}!`;
}

type GreetReturn = ReturnType<typeof greet>; // string
Enter fullscreen mode Exit fullscreen mode

Here, ReturnType extracts the return type of a function. The infer keyword is telling TypeScript to figure out what R should be based on the function's return type.

Generic constraints allow us to restrict the types that can be used with our generics. This is useful for creating more specific and safer types:

interface Lengthwise {
  length: number;
}

function logLength<T extends Lengthwise>(arg: T): void {
  console.log(arg.length);
}

logLength("hello"); // OK
logLength([1, 2, 3]); // OK
logLength(123); // Error: Argument of type 'number' is not assignable to parameter of type 'Lengthwise'
Enter fullscreen mode Exit fullscreen mode

This ensures that logLength can only be called with arguments that have a length property.

Now, let's dive into some more advanced concepts. Type-level programming in TypeScript allows us to create complex type inferences. For example, we can create a type that converts a string to a union of its characters:

type StringToUnion<S extends string> = S extends `${infer C}${infer R}`
  ? C | StringToUnion<R>
  : never;

type Result = StringToUnion<"hello">; // "h" | "e" | "l" | "o"
Enter fullscreen mode Exit fullscreen mode

This type recursively breaks down the string into its characters, creating a union type of all characters.

Another powerful technique is using recursive types. These allow us to create types that reference themselves, which is useful for representing nested structures:

type NestedArray<T> = T | NestedArray<T>[];

const nested: NestedArray<number> = [1, [2, 3], [[4]]];
Enter fullscreen mode Exit fullscreen mode

This type allows arrays to be nested to any depth, all while maintaining type safety.

We can also use advanced type manipulation to create more expressive APIs. For example, we can create a type-safe event emitter:

type EventMap = {
  click: { x: number; y: number };
  focus: undefined;
  input: string;
};

class EventEmitter<T extends EventMap> {
  on<K extends keyof T>(eventName: K, handler: (data: T[K]) => void): void {
    // Implementation
  }

  emit<K extends keyof T>(eventName: K, data: T[K]): void {
    // Implementation
  }
}

const emitter = new EventEmitter<EventMap>();

emitter.on("click", ({ x, y }) => console.log(x, y));
emitter.emit("click", { x: 10, y: 20 });

emitter.on("input", (data) => console.log(data.toUpperCase()));
emitter.emit("input", "hello");

// Error: Argument of type 'string' is not assignable to parameter of type 'undefined'
emitter.emit("focus", "oops");
Enter fullscreen mode Exit fullscreen mode

This ensures that the correct data type is used for each event, catching potential errors at compile-time.

Let's explore how we can use these techniques in real-world scenarios. Imagine we're building a library for handling API requests. We want to ensure that the response type matches the request type:

type ApiEndpoints = {
  "/users": {
    get: { response: User[]; params: { limit: number } };
    post: { response: User; body: Omit<User, "id"> };
  };
  "/users/:id": {
    get: { response: User; params: { id: number } };
    put: { response: User; params: { id: number }; body: Partial<User> };
    delete: { response: void; params: { id: number } };
  };
};

type RequestMethod = "get" | "post" | "put" | "delete";

type EndpointParams<T extends keyof ApiEndpoints, M extends RequestMethod> =
  ApiEndpoints[T][M] extends { params: infer P } ? P : never;

type RequestBody<T extends keyof ApiEndpoints, M extends RequestMethod> =
  ApiEndpoints[T][M] extends { body: infer B } ? B : never;

type ResponseType<T extends keyof ApiEndpoints, M extends RequestMethod> =
  ApiEndpoints[T][M] extends { response: infer R } ? R : never;

function apiRequest<T extends keyof ApiEndpoints, M extends RequestMethod>(
  endpoint: T,
  method: M,
  params: EndpointParams<T, M>,
  body: RequestBody<T, M>
): Promise<ResponseType<T, M>> {
  // Implementation
  return Promise.resolve({} as ResponseType<T, M>);
}

// Usage
apiRequest("/users", "get", { limit: 10 }, undefined).then((users) => {
  users.forEach((user) => console.log(user.name));
});

apiRequest("/users/:id", "put", { id: 1 }, { name: "John" }).then((user) => {
  console.log(user.id, user.name);
});

// Error: Argument of type '{ name: string; }' is not assignable to parameter of type 'never'
apiRequest("/users/:id", "delete", { id: 1 }, { name: "John" });
Enter fullscreen mode Exit fullscreen mode

This setup ensures that we're using the correct parameters, body, and response types for each endpoint and method combination. It's a powerful way to prevent errors and improve developer experience.

Another practical application is in creating type-safe Redux-like state management:

type Action =
  | { type: "INCREMENT"; payload: number }
  | { type: "DECREMENT"; payload: number }
  | { type: "RESET" };

type State = {
  count: number;
};

type ActionMap = {
  INCREMENT: (state: State, payload: number) => State;
  DECREMENT: (state: State, payload: number) => State;
  RESET: (state: State) => State;
};

function createReducer<S, AM extends Record<string, (state: S, payload: any) => S>>(
  actionMap: AM
) {
  return (state: S, action: { type: keyof AM; payload?: any }): S => {
    const handler = actionMap[action.type];
    if (handler) {
      return handler(state, action.payload);
    }
    return state;
  };
}

const reducer = createReducer<State, ActionMap>({
  INCREMENT: (state, payload) => ({ count: state.count + payload }),
  DECREMENT: (state, payload) => ({ count: state.count - payload }),
  RESET: () => ({ count: 0 }),
});

// Usage
const initialState: State = { count: 0 };
console.log(reducer(initialState, { type: "INCREMENT", payload: 5 })); // { count: 5 }
console.log(reducer(initialState, { type: "DECREMENT", payload: 3 })); // { count: -3 }
console.log(reducer(initialState, { type: "RESET" })); // { count: 0 }

// Error: Argument of type '{ type: "UNKNOWN"; }' is not assignable to parameter of type '{ type: "INCREMENT" | "DECREMENT" | "RESET"; payload?: any; }'
console.log(reducer(initialState, { type: "UNKNOWN" }));
Enter fullscreen mode Exit fullscreen mode

This setup ensures that our reducer handles all defined action types correctly, with proper payload types.

These advanced TypeScript techniques allow us to create more expressive, safer, and self-documenting code. They enable us to catch more errors at compile-time, reducing the likelihood of runtime errors and improving overall code quality.

Remember, while these techniques are powerful, it's important to use them judiciously. Overuse can lead to complex type definitions that are hard to understand and maintain. Always strive for a balance between type safety and code readability.

As we continue to explore the depths of TypeScript's type system, we'll discover even more ways to leverage its power. The key is to keep learning, experimenting, and pushing the boundaries of what we can do with types. Happy coding!


Our Creations

Be sure to check out our creations:

Investor Central | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)