We’ve explored the power of TypeScript’s Basic Utility Types and Advanced Utility Types that are provided out-of-the-box. They’re incredible tools for manipulating and transforming existing data types. But what if you encounter a very specific scenario where the built-in utility types just aren’t enough?
This is where Custom TypeScript Utility Types come into play. By understanding a few core TypeScript concepts, you can build your own utility types tailored precisely to your project’s needs. This will give you unlimited control over your type system, allowing you to create higher-level abstractions and achieve even more precise type safety.
Why Create Custom Utility Types?
While TypeScript provides many useful utility types, there are times when you need highly specific type transformation logic that isn’t covered. Creating custom utility types allows you to:
- Enhance Reusability: Define complex type logic once, then use it repeatedly across your codebase.
- Improve Readability: Abstract complex type logic into easily understandable type names.
- Achieve Higher Type Safety: Enforce highly specific type rules to prevent runtime errors.
- Support Domain-Specific Logic: Create types that perfectly align with your unique data models or business logic.
To build custom utility types, we’ll focus on three fundamental concepts: Conditional Types, Mapped Types, and Template Literal Types
Fundamental Concepts for Custom Utility Types
1. Conditional Types (T extends U ? X : Y
)
Conditional Types
allow you to select different types based on a condition. Their syntax is similar to JavaScript’s ternary operator: T extends U ? X : Y
. If type T
is assignable to type U
, then the result is type X
; otherwise, it’s type Y
.
- Syntax:
TypeA extends TypeB ? TypeC : TypeD
- When to Use:
- To create types that depend on the properties or shape of another type.
- In combination with
infer
to extract types from other parts of the type being checked.
Examples:
// Example 1: Selecting a type based on whether it's a string or a number
type IsString<T> = T extends string ? "Yes, it's a string" : "No, it's not a string";
// --- Correct Usage ---
type Result1 = IsString<"hello">; // Result: "Yes, it's a string"
type Result2 = IsString<123>; // Result: "No, it's not a string"
type Result3 = IsString<boolean>; // Result: "No, it's not a string"
// Example 2: Extracting the element type of an array (using 'infer')
type ElementType<T> = T extends (infer U)[] ? U : T;
// --- Correct Usage ---
type StringArrayElement = ElementType<string[]>; // Result: string
type NumberArrayElement = ElementType<number[]>; // Result: number
type NonArrayElement = ElementType<boolean>; // Result: boolean (since it's not an array)
console.log("StringArrayElement:", "string");
console.log("NumberArrayElement:", "number");
// Example 3: Extracting property names if those properties are functions
type FunctionPropertyNames<T> = {
[K in keyof T]: T[K] extends Function ? K : never
}[keyof T];
type FunctionProperties<T> = Pick<T, FunctionPropertyNames<T>>;
interface Service {
id: number;
getName(): string;
isActive: boolean;
greet(message: string): void;
}
// --- Correct Usage ---
type ServiceFunctionNames = FunctionPropertyNames<Service>; // Result: "getName" | "greet"
type ServiceFunctions = FunctionProperties<Service>;
/* Result:
{
getName: () => string;
greet: (message: string) => void;
}
*/
const myService: ServiceFunctions = {
getName: () => "My Service",
greet: (msg: string) => console.log(msg)
};
myService.greet("Hello from service!");
// --- Common Pitfalls / Limitations ---
// Conditional Types can become very complex and hard to read if conditions are nested too deeply.
// Be careful with 'infer' in inappropriate places, as it can lead to unintended inferences.
// For example, `type WhatIsThis<T> = T extends infer U ? U : never;` will always just yield `T`.
2. Mapped Types ([P in K]: T
)
Mapped Types
allow you to create new object types by iterating through the properties (keys) of an existing type and applying a transformation to each property. They use the [P in K]
syntax, where P
is a variable for each key, and K
is a union type of the keys to iterate over.
- Syntax:
{ [P in K]: TypeTransformation }
- When to Use:
- To uniformly modify object properties (e.g., making all properties optional, read-only, or nullable).
- Building new types based on the properties of another type.
- Renaming properties (Key Remapping).
Examples:
interface UserData {
id: number;
name: string;
email: string;
}
// Example 1: Making all properties nullable
type Nullable<T> = { [P in keyof T]: T[P] | null };
// --- Correct Usage ---
type NullableUserData = Nullable<UserData>;
/* Result:
{
id: number | null;
name: string | null;
email: string | null;
}
*/
const userWithNulls: NullableUserData = {
id: 1,
name: "John Doe",
email: null // Email can be null
};
console.log("User with Nulls:", userWithNulls);
// Example 2: Making all properties into getter functions (with Key Remapping)
type Getters<T> = {
[P in keyof T as `get${Capitalize<string & P>}`]: () => T[P]
};
// Note: 'as `get${Capitalize<string & P>}`' is the "Key Remapping" feature introduced in TS 4.1.
// `Capitalize<string & P>` ensures P is a string and its first letter is capitalized.
// --- Correct Usage ---
type UserGetters = Getters<UserData>;
/* Result:
{
getId: () => number;
getName: () => string;
getEmail: () => string;
}
*/
const userGetters: UserGetters = {
getId: () => 1,
getName: () => "Alice",
getEmail: () => "[email protected]"
};
console.log("User Getter Name:", userGetters.getName());
// Example 3: Making all properties Readonly recursively (combined with Conditional Types)
// This is a simplified version; a robust DeepReadonly is more complex.
type DeepReadonly<T> = T extends object ? {
readonly [P in keyof T]: DeepReadonly<T[P]>;
} : T;
interface DeepObject {
a: number;
b: {
c: string;
d: {
e: boolean;
};
};
}
// --- Correct Usage ---
type ImmutableDeepObject = DeepReadonly<DeepObject>;
const immutableObj: ImmutableDeepObject = {
a: 1,
b: {
c: "text",
d: {
e: true
}
}
};
// immutableObj.a = 2; // Error: Cannot assign to 'a' because it is a read-only property.
// immutableObj.b.c = "new text"; // Error: Cannot assign to 'c' because it is a read-only property.
// --- Common Pitfalls / Limitations ---
// Mapped Types operate on object properties. If applied to non-object types,
// the result might not be as expected or could yield an empty type.
// Avoid Key Remapping that results in identical key names, as this can lead to type conflicts.
3. Template Literal Types (${Prefix}${T}${Suffix}
)
Template Literal Types
allow you to create new string literal types by concatenating existing string literal types with union types and generic types. They use JavaScript’s template literal syntax (``
).
- Syntax:
`${Prefix}${TypeVariable}${Suffix}`
- When to Use:
- Untuk membuat string literal types yang dinamis, seperti nama event, kunci objek, atau path URL.
- Memastikan konsistensi penamaan dalam sistem yang besar.
Examples:
type EventCategory = "user" | "product" | "order";
type EventAction = "created" | "updated" | "deleted";
// Example 1: Creating a type for event names
type AppEvent = `${EventCategory}_${EventAction}`;
// --- Correct Usage ---
const userCreatedEvent: AppEvent = "user_created";
const productUpdatedEvent: AppEvent = "product_updated";
// const orderViewedEvent: AppEvent = "order_viewed"; // Error: Type '"order_viewed"' is not assignable to type 'AppEvent'.
// Explanation: Only valid combinations of EventCategory and EventAction are allowed.
console.log("User Created Event:", userCreatedEvent);
// Example 2: Creating a type for object keys with a prefix
type Feature = "settings" | "profile";
type FeatureConfigKeys = `config_${Feature}`;
// --- Correct Usage ---
const settingKey: FeatureConfigKeys = "config_settings";
const profileKey: FeatureConfigKeys = "config_profile";
console.log("Setting Key:", settingKey);
// Example 3: Using infer to extract parts of a string literal (reverse template literal)
type GetIdFromKey<T extends string> = T extends `${infer Prefix}_${infer Id}` ? Id : never;
// --- Correct Usage ---
type ProductId = GetIdFromKey<"product_123">; // Result: "123"
type UserId = GetIdFromKey<"user_abc_456">; // Result: "abc_456" (infer captures up to the first match)
type NoId = GetIdFromKey<"just_a_string">; // Result: "a_string" (still matches the pattern)
type NoMatch = GetIdFromKey<"no_underscore">; // Result: never (if pattern doesn't match)
console.log("Product ID Extracted:", "123" as ProductId);
// --- Common Pitfalls / Limitations ---
// Template Literal Types only work with string literal types.
// If any part is a broad type (like a general `number` or `string`), the result will also be a broad type.
// For example, `type DynamicString = `${number}_${string}`;` will result in `string`, not a specific literal union.
Combining Custom Utility Types
The true power of custom utility types emerges when you combine these concepts.
Combination Example 1: Creating a DeepPartial
Type
The built-in Partial
only makes properties at the first level optional. What if we want all properties, including those in nested objects, to become optional?
type DeepPartial<T> = T extends object ? {
[P in keyof T]?: DeepPartial<T[P]>;
} : T;
interface NestedUser {
id: number;
info: {
name: string;
contact?: {
email: string;
phone: string;
};
};
preferences: {
theme: "dark" | "light";
notifications: boolean;
};
}
// --- Correct Usage ---
type PartialNestedUser = DeepPartial<NestedUser>;
/* Result:
{
id?: number | undefined;
info?: {
name?: string | undefined;
contact?: {
email?: string | undefined;
phone?: string | undefined;
} | undefined;
} | undefined;
preferences?: {
theme?: "dark" | "light" | undefined;
notifications?: boolean | undefined;
} | undefined;
}
*/
const partialUser: PartialNestedUser = {
info: {
contact: {
email: "[email protected]" // Only email provided, phone is optional
}
},
preferences: {
theme: "dark" // Only theme provided, notifications is optional
}
};
console.log("Partial Nested User:", partialUser);
// --- Common Pitfalls / Limitations ---
// Recursive types like DeepPartial must be handled carefully to avoid infinite type evaluation.
// TypeScript has a recursion depth limit (typically 50 levels) which can cause errors if the structure is too deep.
Combination Example 2: PropWithType<T, Type>
– Selecting Properties by Their Type
This is a custom utility type that allows you to select only the properties of an object that have a specific type.
type PropWithType<T, SelectedType> = {
[K in keyof T]: T[K] extends SelectedType ? K : never
}[keyof T];
interface Employee {
id: number;
name: string;
email: string;
hireDate: Date;
salary: number;
isActive: boolean;
}
// --- Correct Usage ---
type StringProps = PropWithType<Employee, string>; // Result: "name" | "email"
type NumberProps = PropWithType<Employee, number>; // Result: "id" | "salary"
type BooleanProps = PropWithType<Employee, boolean>; // Result: "isActive"
type DateProps = PropWithType<Employee, Date>; // Result: "hireDate"
// Can then be combined with Pick:
type EmployeeStringData = Pick<Employee, StringProps>;
/* Result:
{
name: string;
email: string;
}
*/
const employeeString: EmployeeStringData = {
name: "Agus",
email: "[email protected]"
};
console.log("Employee String Data:", employeeString);
// --- Common Pitfalls / Limitations ---
// Be careful with overly broad types (e.g., `any` or `unknown`) as `SelectedType`,
// as they might yield all properties.
// This only checks the type of the property itself, not properties within nested objects.
Building custom utility types is the final step in mastering TypeScript’s type system. By understanding and combining Conditional Types, Mapped Types, and Template Literal Types, you gain the power to create incredibly sophisticated and specific type abstractions, tailored precisely to your project’s unique needs.
This enables you to write code that is safer, more maintainable, and more expressive, pushing the boundaries of what’s possible with data types. Keep experimenting, and you’ll find that TypeScript is an even more flexible and powerful tool than you imagined.
This concludes our series on TypeScript Utility Types. Happy experimenting and building amazing systems with TypeScript!