Skip to main content

Intro to TypeScript Types (custom types, unions, interfaces, and more)

TypeScript types are essential for writing high-quality, maintainable code. By learning to define and use custom types, unions, interfaces, and more, you can create safer and more efficient applications. This article explores TypeScript types, covering their features, best practices, and common pitfalls. Whether you're an experienced developer or new to TypeScript, this guide will help you improve your coding skills.

Defining Custom Types in TypeScript

Creating custom types in TypeScript lets you define reusable type definitions that can be used throughout your codebase. These types can represent the structure of objects, functions, and other data. For example:

type Point = {
x: number;
y: number;
};

const point: Point = { x: 10, y: 20 };

This example defines a Point type with numeric x and y properties. You can then use this type to enforce consistent structure for any point in your application.

Custom types are particularly useful when working with utility types to create more specialized definitions:

type ReadonlyPoint = `Readonly<Point>`;
const fixedPoint: ReadonlyPoint = { x: 0, y: 0 };
// Error: Cannot assign to 'x' because it is a read-only property
// fixedPoint.x = 5;

For complex applications, you can use custom types to model your domain and ensure consistency across components. Check out Convex's best practices for TypeScript for examples of using types in database applications.

Working with Union and Intersection Types

Union and intersection types add flexibility to your TypeScript code, allowing you to model complex relationships between types.

Union types (denoted by |) let a value be one of several types, perfect for handling multiple possible data formats:

type StringOrNumber = string | number;

function processInput(input: StringOrNumber) {
if (typeof input === 'string') {
return input.toUpperCase();
} else {
return input.toFixed(2);
}
}

Intersection types (denoted by &) combine multiple types into one, useful when you need to merge properties:

type Point2D = {
x: number;
y: number;
};

type Point3D = Point2D & { z: number };

const point: Point3D = { x: 10, y: 20, z: 30 };

This method is useful when extending existing types without modifying them. For projects with complex data structures, using both union and discriminated union types can help model state transitions and API responses.

Union types also work well when handling nullable values:

type Result<T> = { success: true; data: T } | { success: false; error: string };

function handleResult<T>(result: Result<T>) {
if (result.success) {
// TypeScript knows result.data exists here
return result.data;
} else {
// TypeScript knows result.error exists here
console.error(result.error);
return null;
}
}

For more examples of applying union types in practice, check out Convex's approach to argument validation which uses these techniques effectively.

Creating TypeScript Interfaces for Better Code Structure

Interfaces define object shapes in TypeScript, creating contracts for how objects should be structured. Unlike type aliases, interfaces can be extended and implemented by classes. For example:

interface User {
name: string;
age: number;
email?: string; // Optional property
greet(): string; // Method signature
}

const user: User = {
name: 'John Doe',
age: 30,
greet() { return `Hello, I'm ${this.name}`; }
};

Interfaces really shine when modeling domain objects or API responses. They help document expectations and catch integration issues early. You can extend interfaces to build on existing definitions:

interface Employee extends User {
employeeId: string;
department: string;
salary: number;
}

// All User properties plus Employee properties are required
const employee: Employee = {
name: 'Jane Smith',
age: 28,
employeeId: 'EMP001',
department: 'Engineering',
salary: 85000,
greet() { return `Hello, I'm ${this.name} from ${this.department}`; }
};

For database applications, interfaces help maintain consistency between data models. Convex's TypeScript best practices demonstrate how to use interface declarations with schema definitions.

Interfaces can also describe function types and class structures, making them versatile for many use cases:

interface Calculator {
add(a: number, b: number): number;
subtract(a: number, b: number): number;
}

class BasicCalculator implements Calculator {
add(a: number, b: number): number { return a + b; }
subtract(a: number, b: number): number { return a - b; }
}

Implementing Type Aliases in TypeScript for Code Reusability

Type aliases create new names for existing types, simplifying complex type definitions and improving readability. For example:

// Simple type alias for a primitive union
type ID = string | number;

// Complex type alias for an object structure
type UserProfile = {
id: ID;
displayName: string;
settings: {
theme: 'light' | 'dark' | 'system';
notifications: boolean;
timezone: string;
};
lastActive?: Date;
};

const user: UserProfile = {
id: "user_123",
displayName: "DevUser",
settings: {
theme: "dark",
notifications: true,
timezone: "UTC-5"
}
};

Unlike interfaces, type aliases can represent more than just object shapes. They can alias primitives, unions, tuples, and other complex types:

// Tuple type
type Point = [number, number];

// Function type
type ClickHandler = (event: MouseEvent) => void;

// Complex union with different shapes
type APIResponse<T> =
| { status: 'success'; data: T; timestamp: number }
| { status: 'error'; error: string; code: number };

Type aliases work well with generics<T> to create reusable, type-safe patterns. For example, you can create a type alias for a paginated response:

type PaginatedResponse<T> = {
items: T[];
totalItems: number;
page: number;
pageSize: number;
hasMore: boolean;
};

// Used with different data types
type UserList = PaginatedResponse<UserProfile>;
type ProductList = PaginatedResponse<Product>;

When creating a data-intensive application, type aliases can help you maintain consistency across your codebase, as shown in Convex's type cookbook.

Using Generics in TypeScript for Flexible Function Definitions

Generics create reusable components that work with a variety of types. They act as type variables, letting you write functions and classes that maintain type safety while working with different data types.

function identity<T>(arg: T): T {
return arg;
}

const stringResult = identity<string>('hello'); // Type: string
const numberResult = identity(42); // Type inference works too: number

Generics work well when creating container-like functions and classes:

// A simple generic stack implementation
class Stack<T> {
private items: T[] = [];

push(item: T): void {
this.items.push(item);
}

pop(): T | undefined {
return this.items.pop();
}

peek(): T | undefined {
return this.items.length ? this.items[this.items.length - 1] : undefined;
}
}

const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
const topNumber = numberStack.pop(); // Type: number | undefined

You can constrain generic types to ensure they have certain properties:

interface HasLength {
length: number;
}

function getLength<T extends HasLength>(arg: T): number {
return arg.length;
}

const len1 = getLength("hello"); // Works with strings
const len2 = getLength([1, 2, 3]); // Works with arrays
// const len3 = getLength(123); // Error: number doesn't have length property

Generic types are heavily used in utility types like Partial<T> and Pick<T, K>. They're also foundational for libraries like React. When working with APIs and data fetching, generics help maintain type safety across asynchronous operations:

async function fetchData<T>(url: string): Promise<T> {
const response = await fetch(url);
return response.json() as T;
}

interface User {
id: number;
name: string;
}

// TypeScript knows this returns Promise<User[]>
const users = await fetchData<User[]>('/api/users');

For more advanced examples of generics in action, check out Convex's functional relationships helpers, which use generics to provide type-safe database queries.

Handling Nullable Types in TypeScript for Safer Code

Nullable types allow a value to be null or undefined, ensuring your code safely handles these values. For example:

// Without strict null checks, this can cause runtime errors
function getLength(text: string) {
return text.length; // Crashes if text is null or undefined
}

// With nullable types, TypeScript forces you to handle these cases
function safeLengthCheck(text: string | null | undefined): number {
// Type guard protects against null/undefined
if (text === null || text === undefined) {
return 0;
}

return text.length; // Safe to use now
}

The TypeScript compiler option strictNullChecks makes this checking mandatory, which greatly reduces null-related bugs.

Working with optional properties becomes safer too:

interface User {
name: string;
email?: string; // Optional property
profile: {
avatar?: string; // Nested optional property
};
}

function getUserEmail(user: User): string {
// Optional chaining handles possible undefined values
return user.email ?? "No email provided";
}

function getProfileImage(user: User): string {
// Optional chaining with nullish coalescing
return user.profile?.avatar ?? "default-avatar.png";
}

When working with union types, you can combine them with nullable types to create comprehensive type definitions that handle all possible states:

type ApiResponse<T> = 
| { status: "loading"; data: null }
| { status: "error"; error: string; data: null }
| { status: "success"; data: T };

function handleResponse<T>(response: ApiResponse<T>): T | null {
switch (response.status) {
case "success":
return response.data;
case "error":
console.error(response.error);
return null;
case "loading":
default:
return null;
}
}

For more advanced patterns when handling nullable types in database contexts, see Convex's argument validation without repetition which demonstrates handling optional fields.

Extending Existing Types with TypeScript's Utility Types

TypeScript provides utility types like Partial<T>, Readonly<T>, Pick<T, K>, and Omit<T, K> to extend existing types. For example:

// Original type
interface User {
id: string;
name: string;
email: string;
password: string;
isAdmin: boolean;
}

// Make all properties optional
type PartialUser = `Partial<User>`;
// For user updates where only some fields might change
const userUpdate: PartialUser = { name: "Jane Doe" };

// Make all properties readonly
type ReadonlyUser = `Readonly<User>`;
const adminUser: ReadonlyUser = {
id: "admin_1",
name: "Admin",
email: "admin@example.com",
password: "hashed_password",
isAdmin: true
};
// adminUser.isAdmin = false; // Error: Cannot assign to 'isAdmin' because it is a read-only property

// Pick specific properties
type UserCredentials = `Pick<User, "email" | "password">`;
// For login forms
const credentials: UserCredentials = {
email: "user@example.com",
password: "secure_password"
};

// Omit specific properties
type PublicUser = `Omit<User, "password" | "id">`;
// For displaying user info publicly
const publicProfile: PublicUser = {
name: "John Smith",
email: "john@example.com",
isAdmin: false
};

utility types are particularly useful when working with form inputs, API responses, and state management. Other common utility types include Record<K, V>, Required<T>, and Exclude<T, U>.

For mapping between different data structures, utility types provide a clean approach:

// Database model
interface UserModel {
user_id: number;
user_name: string;
user_email: string;
created_at: Date;
is_verified: boolean;
}

// Frontend representation
type UserViewModel = {
id: number;
name: string;
email: string;
isVerified: boolean;
};

// Mapping utility
type DatabaseToViewModel<T> = {
[K in keyof T as K extends `user_${infer P}` ?
(P extends 'id' ? 'id' :
P extends 'name' ? 'name' :
P extends 'email' ? 'email' : never) :
(K extends 'is_verified' ? 'isVerified' : never)]:
T[K];
};

// Creates the right shape automatically
type MappedUser = DatabaseToViewModel<UserModel>;

Common Challenges and Solutions

Working with TypeScript types can present several challenges as your codebase grows. Here are some practical solutions to common issues:

Type vs. Interface: Making the Right Choice

Choosing between custom types and interfaces depends on your specific needs:

// Interface: ideal for objects that need extending
interface Product {
id: string;
name: string;
price: number;
}

interface DiscountedProduct extends Product {
discountRate: number;
discountedPrice: number;
}

// Type alias: better for unions, primitives, and advanced type manipulations
type Status = "pending" | "processing" | "shipped" | "delivered";
type ProductWithStatus = Product & { status: Status };

Use interfaces when you need to:

  • Extend types frequently
  • Create class implementations
  • Follow object-oriented patterns

Use type aliases when you need:

  • Union or intersection types
  • Mapped types
  • More complex type manipulations

Working with Complex Type Guards

Type guards ensure type safety when dealing with union types:

type Circle = { kind: "circle"; radius: number };
type Rectangle = { kind: "rectangle"; width: number; height: number };
type Shape = Circle | Rectangle;

function calculateArea(shape: Shape): number {
// Discriminated union pattern
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
}
}

For more complex scenarios, consider custom type guards:

function isCircle(shape: Shape): shape is Circle {
return shape.kind === "circle";
}

// Now TypeScript knows shape is Circle within this block
if (isCircle(shape)) {
console.log(`Circle with radius ${shape.radius}`);
}

For practical examples of implementing type guards in database contexts, see Convex's code spelunking article which explores TypeScript's advanced type checking capabilities.

Recursive Types for Tree-like Structures

When modeling nested data like file systems or comment threads, recursive types can help:

// Self-referential type for comment threads
type Comment = {
id: string;
text: string;
author: string;
replies: Comment[];
};

// Tree structure
type FileSystemNode = {
name: string;
type: "file" | "directory";
// Only directories have children
children?: FileSystemNode[];
};

Final Thoughts on TypeScript Types

TypeScript's type system catches errors early and clarifies code intent. As you build more complex applications, its true value becomes apparent - enabling confident refactoring, improving documentation through types, and enhancing team collaboration. Balance type safety with development speed, and you'll find TypeScript an invaluable addition to your toolkit.