Generics in TypeScript allow you to create reusable components and functions that work with various data types while maintaining type safety. They let you define placeholders for types that are determined when the component or function is used. This makes your code flexible and versatile, adapting to different data types without sacrificing type information. Generics can be used in functions, types, classes, and interfaces.
Basic Syntax
The syntax for using generics involves angle brackets (<>
) to enclose a type parameter, representing a placeholder for a specific type.
Using Generics with Functions
Here's how you can use generics in functions:
function identity<T>(arg: T): T {
return arg;
}
const result = identity<string>("Hello, TypeScript!");
In this example, the identity function uses a generic type parameter T
to denote that the input argument and the return value have the same type. When calling the function, you provide a specific type argument within the angle brackets (<string>
) to indicate that you're using the function with strings.
Passing Type Parameters Directly
Generics can also be useful when working with custom types:
type ProgrammingLanguage = {
name: string;
};
function identity<T>(value: T): T {
return value;
}
const result = identity<ProgrammingLanguage>({ name: "TypeScript" });
In this example, the identity
function uses a generic type parameter T
, which is explicitly set to ProgrammingLanguage
when the function is called. Thus, the result variable has the type ProgrammingLanguage
. If you did not provide the explicit type parameter, TypeScript would infer the type based on the provided argument, which in this case would be { name: string }
.
Another common scenario involves using generics to handle data fetched from an API:
async function fetchApi(path: string) {
const response = await fetch(`https://example.com/api${path}`);
return response.json();
}
This function returns a Promise<any>
, which isn't very helpful for type-checking. We can make this function type-safe by using generics:
type User = {
name: string;
};
async function fetchApi<ResultType>(path: string):Promise<ResultType> {
const response = await fetch(`https://example.com/api${path}`);
return response.json();
}
const data = await fetchApi<User[]>('/users');
By turning the function into a generic one with the ResultType
parameter, the return type of the function is now Promise<ResultType>
.
Default Type Parameters
To avoid always specifying the type parameter, you can set a default type:
async function fetchApi<ResultType = Record<string, any>>(path: string): Promise<ResultType> {
const response = await fetch(`https://example.com/api${path}`);
return response.json();
}
const data = await fetchApi('/users');
console.log(data.a);
With a default type of Record<string, any>
, TypeScript will recognize data as an object with string
keys and any
values, allowing you to access its properties.
Type Parameter Constraints
In some situations, a generic type parameter needs to allow only certain shapes to be passed into the generic. To create this additional layer of specificity to your generic, you can put constraints on your parameter. Imagine you have a storage constraint where you are only allowed to store objects that have string values for all their properties. For that, you can create a function that takes any object and returns another object with the same keys as the original one, but with all their values transformed to strings this function will be called stringifyObjectKeyValues
.
This function is going to be a generic function. This way, you are able to make the resulting object have the same shape as the original object. The function will look like this:
function stringifyObjectKeyValues<T extends Record<string, any>>(obj: T) {
return Object.keys(obj).reduce((acc, key) => ({
...acc,
[key]: JSON.stringify(obj[key])
}), {} as { [K in keyof T]: string })
}
In this code, stringifyObjectKeyValues
uses the reduce
array method to iterate over an array of the original keys, stringifying the values and adding them to a new array.
To make sure the calling code is always going to pass an object to your function, you are using a type constraint on the generic type T
, as shown in the following highlighted code:
function stringifyObjectKeyValues<T extends Record<string, any>>(obj: T) {
// ...
}
extends Record<string, any>
is known as generic type constraint, and it allows you to specify that your generic type must be assignable to the type that comes after the extends
keyword. In this case, Record<string, any>
indicates an object with keys of type string and values of type any. You can make your type parameter extend any valid TypeScript type.
When calling reduce
, the return type of the reducer function is based on the initial value of the accumulator. The {} as { [K in keyof T]: string }
code sets the type of the initial value of the accumulator to { [K in keyof T]: string }
by using a type cast on an empty object, {}
. The type { [K in keyof T]: string }
creates a new type with the same keys as T
.
The following code shows the implementation of your stringifyObjectKeyValues
function:
function stringifyObjectKeyValues<T extends Record<string, any>>(obj: T) {
return Object.keys(obj).reduce((acc, key) => ({
...acc,
[key]: JSON.stringify(obj[key])
}), {} as { [K in keyof T]: string })
}
const stringifiedValues = stringifyObjectKeyValues({ a: "1", b: 2, c: true, d: [1, 2, 3]})
*/
{
a: string;
b: string;
c: string;
d: string;
}*/
Using Generics with Interfaces, Classes, and Types
When creating interfaces and classes in TypeScript, it can be useful to use generic type parameters to set the shape of the resulting objects. For example, a class could have properties of different types depending on what is passed in to the constructor. In this section, you will see the syntax for declaring generic type parameters in classes and interfaces and examine a common use case in HTTP applications.
Generic Interfaces and Classes
Interfaces:
interface MyInterface<T> {
field: T
}
This declares an interface that has a property field
whose type is determined by the type passed in to T
.
Classes:
class MyClass<T> {
field: T
constructor(field: T) {
this.field = field
}
}
One common use case of generic interfaces/classes is for when you have a field whose type depends on how the client code is using the interface/class. Say you have an HttpApplication
class that is used to handle HTTP requests to your API, and that some context value is going to be passed around to every request handler. One such way to do this would be:
class HttpApplication<Context> {
context: Context
constructor(context: Context) {
this.context = context;
}
get(url: string, handler: (context: Context) => Promise<void>): this {
return this;
}
}
This class stores a context
whose type is passed in as the type of the argument for the handler
function in the get
method. During usage, the parameter type passed to the get
handler would correctly be inferred from what is passed to the class constructor.
const context = { someValue: true };
const app = new HttpApplication(context);
app.get('/api', async () => {
console.log(context.someValue)
});
In this implementation, TypeScript will infer the type of context.someValue
as boolean
.
Generic Types
Generic types can be used to create helper types, such as Partial, which makes all properties of a type optional:
type Partial<T> = {
[P in keyof T]?: T[P];
};
To understand the power of generic types, let's consider an example involving an object that stores shipping costs between different stores in a business distribution network. Each store is identified by a three-character code:
{
ABC: {
ABC: null,
DEF: 12,
GHI: 13,
},
DEF: {
ABC: 12,
DEF: null,
GHI: 17,
},
GHI: {
ABC: 13,
DEF: 17,
GHI: null,
},
}
In this object:
- Each top-level key represents a store.
- Each nested key represents the cost to ship to another store.
- The cost from a store to itself is
null
.
To ensure consistency (e.g., the cost from a store to itself is always null
and the costs to other stores are numbers), we can use a generic helper type.
type IfSameKeyThenTypeOtherwiseOther<Keys extends string, T, OtherType> = {
[K in Keys]: {
[SameKey in K]: T;
} & {
[OtherKey in Exclude<Keys, K>]: OtherType;
};
};
Breakdown this type
- Generics Declaration:
-
Keys extends string
:Keys
is a type parameter that must be a union ofstring
literals. It represents all possible keys of the object. -
T
: A type parameter representing the type to be used when a key matches itself. -
OtherType
: A type parameter representing the type to be used when a key does not match itself.
- Mapped Type:
[K in Keys]:
This is a mapped type that iterates over each key K
in the union type Keys
.
- Inner Object Type:
The inner object type is divided into two parts:
{
[SameKey in K]: T;
}
Here, [SameKey in K]
creates a property where SameKey
is exactly K
. This means if the key of the outer object is K
, this inner key is also K
, and its type is T
.
{
[OtherKey in Exclude<Keys, K>]: OtherType;
}
This part uses Exclude<Keys, K>
to create properties for all other keys in Keys
except K
. The type of these properties is OtherType
.
- Combining with Intersection (
&
):
{
[K in Keys]: {
[SameKey in K]: T;
} & {
[OtherKey in Exclude<Keys, K>]: OtherType;
};
}
The two inner object parts are combined using the intersection type &
. This means the resulting type will include properties from both parts.
Example
type StoreCode = 'ABC' | 'DEF' | 'GHI';
type ShippingCosts = IfSameKeyThenTypeOtherwiseOther<StoreCode, null, number>;
const shippingCosts: ShippingCosts = {
ABC: {
ABC: null, // T (null) because key is same as parent key
DEF: 12, // OtherType (number) because key is different
GHI: 13 // OtherType (number) because key is different
},
DEF: {
ABC: 12, // OtherType (number) because key is different
DEF: null, // T (null) because key is same as parent key
GHI: 17 // OtherType (number) because key is different
},
GHI: {
ABC: 13, // OtherType (number) because key is different
DEF: 17, // OtherType (number) because key is different
GHI: null // T (null) because key is same as parent key
}
};
Explanation
- For the key
ABC
inshippingCosts
:-
ABC: null
matches the outer key, so it gets the typeT
(null). -
DEF: 12
andGHI: 13
do not match the outer key, so they get the typeOtherType
(number). - This pattern repeats for the keys
DEF
andGHI
, ensuring that the cost from a store to itself is alwaysnull
, while the cost to other stores is always anumber
.
-
Summary
The IfSameKeyThenTypeOtherwiseOther
type ensures consistency in the shape of an object where:
- If a key matches its own name, it gets a specific type
T
. - If a key does not match its own name, it gets another type
OtherType
.
This is particularly useful for scenarios like our shipping costs example, where certain keys require specific types, ensuring type safety and consistency across the object.
Creating Mapped Types with Generics
Mapped types allow you to create new types based on existing ones. For instance, you can create a type that transforms all properties of a given type to booleans:
type BooleanFields<T> = {
[K in keyof T]: boolean;
};
type User = {
email: string;
name: string;
};
type UserFetchOptions = BooleanFields<User>;
This results in:
type UserFetchOptions = {
email: boolean;
name: boolean;
};
Creating Conditional Types with Generics
Conditional types are generic types that resolve differently based on a condition:
type IsStringType<T> = T extends string ? true : false;
This type checks if T extends string and returns true if it does, otherwise false.
Top comments (0)