📚 TypeScript Fundamentals series
- Part 1: Describing Your Data: interfaces, type aliases, unions, tuples
- Part 2: Writing Safer Code: inference, strict mode,
unknown, narrowing- Part 3: Reusable Types: generics, utility types, enums vs literal unions (you are here)
Each part stands on its own. Read them in order, or jump straight to the one you need.
As your codebase grows you'll find yourself writing the same type over and over with small variations: a list of users, a list of orders, a "user but with every field optional". TypeScript has tools for exactly this, so you can write a type once and reuse it. This part covers generics, the built-in utility types, and the question of how to model a fixed set of values.
Who is this part for? Anyone comfortable reading basic TypeScript. If you haven't read the earlier parts, this is all you need to know:
Quick refresher. An
interfaceortypedescribes the shape of an object. A union likestring | numbermeans "one of these types", and a literal type like'admin'means exactly that one value.keyof Tgives you a union of the property names ofT. (Part 1 covers the first two in depth.)
We'll use this User shape throughout:
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
}
Generics
The problem
Imagine a function that returns the first item of an array. How do you type it?
function firstNumber(items: number[]): number | undefined {
return items[0];
}
function firstString(items: string[]): string | undefined {
return items[0];
}
Writing one per type doesn't scale. You could use any, but then you lose all type information:
function first(items: any[]): any {
return items[0];
}
const n = first([1, 2, 3]); // n is any. TypeScript has forgotten it's a number
The solution
A generic is a type placeholder, written in angle brackets, that gets filled in when the function is used:
function first<T>(items: T[]): T | undefined {
return items[0];
}
const n = first([1, 2, 3]); // T is inferred as number, so n is number | undefined
const s = first(["a", "b"]); // T is inferred as string
const u = first<User>([]); // or specify it explicitly
Read <T> as "I don't know what type just yet - but I will when I call the function". The type goes in, and the same type comes out, with nothing lost along the way.
You've already used generics without realising: Array<T> (the same as T[]) and Promise<T> are both generic types.
Generic types and interfaces
Generics aren't just for functions. They're great for wrappers that look the same regardless of the data inside:
interface ApiResponse<T> {
data: T;
fetchedAt: Date;
}
const userResponse: ApiResponse<User> = { data: someUser, fetchedAt: new Date() };
const listResponse: ApiResponse<User[]> = { data: [someUser], fetchedAt: new Date() };
One definition, any payload type.
Constraints
Sometimes "any type" is too loose. Use extends to say "any type, as long as it has at least this shape":
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}
findById(users, 1); // ✅ User has an id
findById([{ id: 1 }], 1); // ✅
findById(["a", "b"], 1); // ❌ string has no 'id'
A particularly useful constraint combines generics with keyof, to make sure a key actually exists on an object and that the return type matches it:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const email = getProperty(someUser, "email"); // type is string
const bad = getProperty(someUser, "emial"); // ❌ not a key of User
Utility Types
TypeScript ships with a set of built-in generic types that transform other types. They save you from writing near-duplicate interfaces. These are the ones you'll reach for most:
| Utility | What it does |
|---|---|
Partial<T> |
Makes every property optional |
Required<T> |
Makes every property required |
Readonly<T> |
Makes every property read-only |
Pick<T, K> |
Keeps only the listed properties |
Omit<T, K> |
Removes the listed properties |
Record<K, V> |
An object with keys K and values V
|
Here they are in practice:
// Partial: great for update functions where only some fields change
function updateUser(id: number, changes: Partial<User>) { /* ... */ }
updateUser(1, { name: "Sam" }); // ✅ no need to pass every field
// Pick: a smaller view of a type
type UserPreview = Pick<User, 'id' | 'name'>;
// { id: number; name: string }
// Omit: everything except some fields
type NewUser = Omit<User, 'id'>; // the shape before the database assigns an id
// { name: string; email: string; role: ... }
// Readonly: prevent accidental mutation
const config: Readonly<User> = someUser;
config.name = "Other"; // ❌ cannot assign, it is read-only
// Record: a lookup table
type RoleLabels = Record<User['role'], string>;
const labels: RoleLabels = {
admin: "Administrator",
editor: "Editor",
viewer: "Read-only",
};
The Record example has a nice bonus: if you add a new role to User, TypeScript will complain that labels is missing a key.
Two more worth knowing about:
-
ReturnType<typeof fn>gives you the type a function returns, which is handy when you don't want to write it out twice. -
Awaited<T>unwraps aPromise, soAwaited<Promise<string>>isstring.
The big idea is that you define a type once (here, User) and derive the others from it. When User changes, everything derived from it updates automatically, with no copies to keep in sync.
Enums vs Literal Unions
Often you need a fixed set of allowed values, like roles, statuses or directions. TypeScript offers a few ways to do it.
Enums
An enum is a TypeScript feature that creates a named set of constants:
enum Role {
Admin = 'admin',
Editor = 'editor',
Viewer = 'viewer',
}
function canEdit(role: Role) {
return role === Role.Admin || role === Role.Editor;
}
canEdit(Role.Admin); // ✅
canEdit('admin'); // ❌ a plain string isn't assignable to the Role enum
Enums work, but they have some quirks. Unlike most TypeScript features, an enum isn't just a type: it generates real JavaScript code at runtime. Numeric enums (the default, when you don't assign string values) also allow some surprising assignments, and values must be referenced via the enum rather than as plain strings.
Literal unions
The lighter alternative is a union of literal types, which we met in Part 1:
type Role = 'admin' | 'editor' | 'viewer';
function canEdit(role: Role) {
return role === 'admin' || role === 'editor';
}
canEdit('admin'); // ✅
canEdit('owner'); // ❌ not in the union
There's no runtime code at all, since it's purely a type, and the values are just ordinary strings, which makes them easy to serialise, log and compare with data from an API.
as const objects: when you need the values at runtime
A literal union is only a type, so you can't loop over it or reference Role.Admin as a value. If you want both, define a constant object and derive the type from it:
const Role = {
Admin: 'admin',
Editor: 'editor',
Viewer: 'viewer',
} as const;
type Role = typeof Role[keyof typeof Role]; // 'admin' | 'editor' | 'viewer'
Role.Admin; // 'admin', usable as a value
Object.values(Role); // loop over every role
function canEdit(role: Role) { /* ... */ }
as const tells TypeScript to treat the object's values as exact literals instead of widening them to string. Notice that the value Role and the type Role share a name, which is allowed because values and types live in separate namespaces.
Which should you use?
For most projects, start with a literal union. It's the simplest option and needs no runtime code. Reach for an as const object when you also need the values at runtime. Enums are still perfectly valid, and plenty of codebases use them, so if your team already does, consistency matters more than switching. As with interfaces vs types, the important thing is to pick one convention and stick to it.
Putting It Together
Here's a small example that uses generics, a union and a discriminant, so you can see how the pieces combine. A generic result type that works for any payload:
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
function parseAge(input: string): Result<number> {
const age = Number(input);
if (Number.isNaN(age) || age < 0) {
return { ok: false, error: `"${input}" is not a valid age` };
}
return { ok: true, value: age };
}
const result = parseAge("42");
if (result.ok) {
console.log(result.value + 1); // ✅ value is a number here
} else {
console.log(result.error); // ✅ error is a string here
}
Result<T> is reusable for any type (Result<User>, Result<string[]>), and the ok field narrows the union so you can only touch value on success and error on failure.
Wrapping Up
Generics are type placeholders that let you write a function or type once and use it with many types without losing type information.
Constraints (
T extends ...) narrow what a generic accepts, andK extends keyof Tties a key to an object.Utility types (
Partial,Pick,Omit,Readonly,Record) let you derive new types from existing ones instead of copying them.For a fixed set of values, start with a literal union, use an
as constobject if you also need runtime values, and treat enums as a valid option if your team prefers them.
Continue the Series
Part 1: Describing Your Data: interfaces, type aliases, unions and tuples.
Part 2: Writing Safer Code: inference, strict mode,
unknownand narrowing.
Top comments (1)
tr.ee/dev-to