Generics - The most intimidating TypeScript feature
FSMD Fahid Sarker
Senior Software Engineer · June 28, 2024
Mastering TypeScript Generics: 10 Essential Tips
Hello Wizards! Today we have a treat for you. We're going to delve into 10 tips to make you a master of TypeScript generics. By the end of this post, you'll understand a lot more about generics and be confident enough to use them in your code. If you don't know what generics are or how to use them, then you are missing out. They give you the power to create abstractions, write DRY (Don't Repeat Yourself) code, and minimize the need for excess type annotations. TypeScript without generics would be absolutely awful. Even if you've never written them yourself, you've probably used them thousands of times.
Let's get started!
1. What is a Generic?
Generics refer to several different patterns. The first pattern is demonstrated here with myGenericType
. Initially, it has data
set to any
. We can pass a type to it, making it a type function that takes an argument.
Code.typescripttype MyGenericType<TData> = { data: TData; }; // Error: Generic type 'MyGenericType' requires one type argument. let example1: MyGenericType; let example2: MyGenericType<string> = { data: "First Name" }; let example3: MyGenericType<number> = { data: 42 };
This allows us to create type helpers and functions that can generate other types.
2. Generic Functions
You can create functions with a type helper mapped over the top. Consider this makeFetch
function:
Code.typescriptasync function makeFetch<TData>(url: string): Promise<TData> { const res = await fetch(url); const data: TData = await res.json(); return data; }
This function maps a type argument, making it a generic function. It returns a promise typed with TData
.
3. Built-in Generics
Many generics exist in the language. For example, the Set
class is a built-in generic:
Code.typescriptlet mySet = new Set<number>(); mySet.add(1); // mySet.add("string"); // Error: Argument of type 'string' is not assignable to parameter of type 'number'.
Generics are common in libraries like React, where hooks such as useState
, useRef
, and useReducer
often require type arguments.
4. Inference with Generics
Generics can often infer types from runtime arguments.
Code.typescriptfunction addIdToObject<T extends { id?: string }>(obj: T): T & { id: string } { return { ...obj, id: "123", }; } let objWithId = addIdToObject({ name: "Alice" }); // objWithId: { name: 'Alice', id: '123' }
Here, TypeScript infers the type without explicit type arguments, making the code cleaner.
5. Combining Awaited and ReturnType
You can combine utility types like Awaited
and ReturnType
to create advanced helpers:
Code.typescripttype GetPromiseReturnType<T extends (...args: any) => any> = Awaited< ReturnType<T> >;
This helper type takes a function that returns a promise and infers the resolved type.
6. Constrained Generics
You can constrain generics to ensure valid inputs:
Code.typescriptfunction getKeyWithHighestValue<TObj extends Record<string, number>>( obj: TObj ) { // implementation } let result = getKeyWithHighestValue({ a: 1, b: 3, c: 2 }); // result: { key: "b", value: 3 }
Constrained generics validate inputs at the type level, reducing runtime errors.
7. Type Assertions with Generics
Sometimes you need to override TypeScript's inference using type assertions:
Code.typescriptfunction typedObjectKeys<TObj>(obj: TObj): (keyof TObj)[] { return Object.keys(obj) as (keyof TObj)[]; }
This ensures TypeScript respects your expected return type.
8. Multiple Generics
You can use multiple generics for more specific typing:
Code.typescriptfunction getValue<TObj, TKey extends keyof TObj>( obj: TObj, key: TKey ): TObj[TKey] { return obj[key]; } let value = getValue({ a: 1, b: "string", c: true }, "b"); // value: string
This enables advanced type inference across multiple parameters.
9. Default Type Parameters
You can set default type parameters:
Code.typescriptfunction createSet<T = string>(): Set<T> { return new Set<T>(); } let defaultSet = createSet(); // defaultSet: Set<string>
Default type parameters provide sensible defaults while allowing overrides.
10. Using Schemas for Type Verification
Using libraries like Zod for runtime verification:
Code.typescriptimport { z, ZodSchema } from "zod"; function makeFetchWithSchema<TData>( url: string, schema: ZodSchema<TData> ): Promise<TData> { return fetch(url) .then((res) => res.json()) .then((data) => schema.parse(data)); } const schema = z.object({ firstName: z.string(), lastName: z.string(), }); makeFetchWithSchema("https://example.com", schema).then((data) => console.log(data) ); // data: { firstName: string, lastName: string }
Generics combined with schema validation ensure type safety at both compile time and runtime.
Until next time, keep coding and may the generics be with you!