DEV Community

Cover image for TypeScript Fundamentals, Part 3: Reusable Types
Grant Riordan
Grant Riordan

Posted on

TypeScript Fundamentals, Part 3: Reusable Types

📚 TypeScript Fundamentals series

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 interface or type describes the shape of an object. A union like string | number means "one of these types", and a literal type like 'admin' means exactly that one value. keyof T gives you a union of the property names of T. (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';
}
Enter fullscreen mode Exit fullscreen mode

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];
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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() };
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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",
};
Enter fullscreen mode Exit fullscreen mode

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 a Promise, so Awaited<Promise<string>> is string.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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) { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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, and K extends keyof T ties 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 const object if you also need runtime values, and treat enums as a valid option if your team prefers them.

Continue the Series

Top comments (1)

Collapse
 
suppdevbot profile image
DEV SUPPORTS •
You need to verify your account.
Enter fullscreen mode Exit fullscreen mode

tr.ee/dev-to