Ilustrasi TypeScript Advanced Utility Types yang membentuk tipe dinamis

TypeScript Advanced Utility Types: Building Dynamic and Complex Types

In our previous article, we delved into TypeScript’s Basic Utility Types like Partial, Readonly, Pick, Omit, Record, and Exclude. You’ve likely realized just how powerful they are for manipulating and optimizing your data types. Now, it’s time to take another step forward.

TypeScript offers another set of more advanced utility types, allowing us to work with type inference, required properties, nullable values, and even asynchronous types in incredibly dynamic ways. Mastering these utility types will elevate your understanding of TypeScript to the next level, enabling you to build much more flexible and robust systems.


Why Learn Advanced Utility Types?

As projects grow in complexity, you’ll often encounter scenarios where data types can’t be statically defined quite so simply. You might need to:

  • Change optional properties to required ones.
  • Remove null or undefined values from a type.
  • Get the return type of a function without actually calling it.
  • Extract the parameter types of a function or constructor.
  • Extract the value type from a Promise.

These advanced utility types are specifically designed for such scenarios, allowing for intelligent type inference and complex type transformations at compile-time.


Essential Advanced Utility Types You Must Know

Here are some advanced utility types that will prove extremely useful in your development, complete with explanations and code examples demonstrating correct usage and common pitfalls/limitations:


1. Required

Required<Type> is the inverse of Partial<Type>. It takes all properties of the given Type and makes them required (non-optional).

  • Syntax: Required<T>
  • When to Use:
    • When you have a type with optional properties, but in a specific context (e.g., after validation or initialization), you want to ensure all those properties are present.
    • Creating a “complete” representation of a type.

Example:

interface UserSettings {
    theme?: string;
    notificationsEnabled?: boolean;
    language?: string;
}

// All properties in CompleteUserSettings become required
type CompleteUserSettings = Required<UserSettings>;

// --- Correct Usage ---
const myFullSettings: CompleteUserSettings = {
    theme: "dark",
    notificationsEnabled: true,
    language: "en"
};

// --- Common Pitfalls / Limitations ---
// const incompleteSettings: CompleteUserSettings = {
//     theme: "light" // Error: Property 'notificationsEnabled' is missing in type '{ theme: string; }' but required in type 'CompleteUserSettings'.
// };
// Explanation: After using `Required`, all properties (even previously optional ones) must be provided.

console.log("Full Settings:", myFullSettings);

2. NonNullable

NonNullable<Type> constructs a new type by excluding null and undefined from the given Type. This is incredibly useful when you’re dealing with union types that might contain nullable values.

  • Syntax: NonNullable<T>
  • When to Use:
    • When you want to ensure that a value will never be null or undefined after performing a check (e.g., inside a guard clause).
    • Purifying data types that might come from uncertain inputs.

Example:

type MyValue = string | number | null | undefined;

// NonNullableValue type will only be 'string | number'
type NonNullableValue = NonNullable<MyValue>;

// --- Correct Usage ---
const validString: NonNullableValue = "hello";
const validNumber: NonNullableValue = 123;

function processValue(value: NonNullableValue) {
    console.log("Processing:", value);
}

processValue(validString); // Valid
processValue(validNumber); // Valid

// --- Common Pitfalls / Limitations ---
// const invalidNull: NonNullableValue = null; // Error: Type 'null' is not assignable to type 'string | number'.
// const invalidUndefined: NonNullableValue = undefined; // Error: Type 'undefined' is not assignable to type 'string | number'.

// Example usage in a function:
function getUserName(user: { name: string | null }): NonNullable<string> {
    // With NonNullable, we assert the result is not null.
    // TypeScript will remind us to handle the null case.
    return user.name!; // Using the non-null assertion operator (use with caution)
    // Or better:
    // if (user.name === null) {
    //    throw new Error("User name is null");
    // }
    // return user.name;
}

const user = { name: "Alice" };
console.log("User Name:", getUserName(user));

3. ReturnType

ReturnType<Type> extracts the return type of a function.

  • Syntax: ReturnType<T extends (...args: any) => any>
  • When to Use:
    • When you want to define the type of a variable that will hold the result of a function call, without having to rewrite the function’s return type.
    • Useful for creating DTO (Data Transfer Object) types based on the output of an API function.

Example:

function getUserData(id: number, name: string) {
    return { id, name, createdAt: new Date() };
}

async function fetchPost(postId: number) {
    const response = await fetch(`https://api.example.com/posts/${postId}`);
    return (await response.json()) as { id: number; title: string; body: string };
}

// Get the return type of the getUserData function
type UserData = ReturnType<typeof getUserData>;

// Get the return type of the fetchPost function (after the Promise resolves)
type PostData = Awaited<ReturnType<typeof fetchPost>>; // Using Awaited (will be discussed next)

// --- Correct Usage ---
const userData: UserData = {
    id: 1,
    name: "Alice",
    createdAt: new Date()
};

const postData: PostData = {
    id: 101,
    title: "My First Post",
    body: "Lorem ipsum..."
};

// --- Common Pitfalls / Limitations ---
// type InvalidReturnType = ReturnType<string>; // Error: Type 'string' does not satisfy the constraint '(...args: any) => any'.
// Explanation: ReturnType can only be used on types that are functions.

console.log("User Data Type Example:", userData);
console.log("Post Data Type Example:", postData);

4. Parameters

Parameters<Type> extracts the parameter types of a function in the form of a tuple type (an array with specific types for each index).

  • Syntax: Parameters<T extends (...args: any) => any>
  • When to Use:
    • When you want to create a wrapper function or middleware that accepts the exact same parameters as another function.
    • To define the argument types that will be passed to a function.

Example:

function registerUser(username: string, email: string, isActive: boolean) {
    console.log(`Registering ${username} with email ${email}. Active: ${isActive}`);
}

// Get the parameter types of the registerUser function
type RegisterUserParams = Parameters<typeof registerUser>;

// --- Correct Usage ---
const paramsForUser1: RegisterUserParams = ["john_doe", "[email protected]", true];

function logAndCall(func: (...args: RegisterUserParams) => void, ...args: RegisterUserParams) {
    console.log("Calling function with params:", args);
    func(...args);
}

logAndCall(registerUser, "jane_doe", "[email protected]", false);

// --- Common Pitfalls / Limitations ---
// const invalidParams: RegisterUserParams = ["alice"]; // Error: Type '[string]' is not assignable to type '[username: string, email: string, isActive: boolean]'. Source has 1 element(s) but target requires 3.
// Explanation: The number and types of parameters must match the generated tuple.

// type InvalidParamsType = Parameters<number>; // Error: Type 'number' does not satisfy the constraint '(...args: any) => any'.
// Explanation: Parameters can only be used on function types.

console.log("Parameters for User 1:", paramsForUser1);

5. ConstructorParameters

ConstructorParameters<Type> extracts the parameter types of a class constructor in the form of a tuple type.

  • Syntax: ConstructorParameters<T extends new (...args: any) => any>
  • When to Use:
    • When you want to define the argument types that will be passed to a class’s constructor.
    • Useful in dependency injection or factory patterns that need to replicate constructor arguments.

Example:

class ProductService {
    constructor(private apiUrl: string, private apiKey: string) {
        // ...
    }

    fetchProducts() {
        // ...
    }
}

// Get the parameter types of the ProductService constructor
type ProductServiceCtorParams = ConstructorParameters<typeof ProductService>;

// --- Correct Usage ---
const serviceArgs: ProductServiceCtorParams = ["https://api.products.com", "mysecretkey123"];

const myProductService = new ProductService(...serviceArgs);

// --- Common Pitfalls / Limitations ---
// const invalidCtorArgs: ProductServiceCtorParams = ["only_one_arg"]; // Error: Type '[string]' is not assignable to type '[apiUrl: string, apiKey: string]'.
// Explanation: The number and types of parameters must match the class constructor.

// type InvalidCtorType = ConstructorParameters<Function>; // Error: Type 'Function' does not satisfy the constraint 'new (...args: any) => any'.
// Explanation: ConstructorParameters can only be used on constructor types (classes).

console.log("Product Service Arguments:", serviceArgs);

6. Awaited

Awaited<Type> is a utility type designed to get the type of a value that has been resolved by a Promise. It recursively unwraps Promises until it reaches the underlying non-Promise type.

  • Syntax: Awaited<T>
  • When to Use:
    • When you’re working with asynchronous functions and want to get the actual data type that will be produced after await is performed.
    • Extremely useful for accurate type inference in async/await environments.

Example:

type MyPromiseString = Promise<string>;
type MyNestedPromiseNumber = Promise<Promise<number>>;
type MyDirectValue = boolean;

// Awaited will "unwrap" the Promise
type ResolvedString = Awaited<MyPromiseString>;         // Result: string
type ResolvedNumber = Awaited<MyNestedPromiseNumber>;   // Result: number
type ResolvedBoolean = Awaited<MyDirectValue>;          // Result: boolean

// --- Correct Usage ---
async function fetchData(): Promise<{ data: string }> {
    return Promise.resolve({ data: "Some data" });
}

type DataResult = Awaited<ReturnType<typeof fetchData>>; // Combines ReturnType and Awaited

const processedData: DataResult = { data: "Processed data" }; // Conforms to { data: string }

// --- Common Pitfalls / Limitations ---
// const invalidAwaited: Awaited<string>; // This won't produce a type error, as Awaited is designed to return the original type if it's not a Promise.
// Explanation: Awaited will work on non-Promise types too, returning their original type. The limitation is if you expect a Promise but the original value isn't one, there might be a logic error.

console.log("Resolved String Type:", "Hello World" as ResolvedString);
console.log("Resolved Number Type:", 123 as ResolvedNumber);
console.log("Data Result Type:", processedData);

Advanced utility types in TypeScript open the door to a higher level of type abstraction and safety. By mastering Required, NonNullable, ReturnType, Parameters, ConstructorParameters, and Awaited, you can write more expressive, safer, and more refactorable code.

They allow you to work with type inference intelligently, elegantly handle nullable scenarios, and precisely manage function and constructor types. This is a crucial step toward becoming a more proficient TypeScript developer.

Keep experimenting with these utility types. Next, we’ll go even further by learning how to build your own custom utility types, which will give you complete control over type manipulation in TypeScript!

Spread the love

Leave a Reply

Your email address will not be published. Required fields are marked *

Back To Top