Intro to TypeScript Types (custom types, unions, interfaces, and more)
You write a function that accepts a user object. Three files and two refactors later, a teammate passes in the wrong shape. The app crashes at runtime, and TypeScript didn't catch it. Sound familiar?
That usually means the types weren't defined clearly enough. TypeScript's type system is powerful, but only if you use it intentionally. This guide walks through the core building blocks: custom types, unions, interfaces, generics, and more, with the kind of practical depth that makes type errors show up at compile time instead of production.
Defining Custom Types in TypeScript
Creating custom types lets you define reusable type definitions you can share across your codebase. They can represent object shapes, function signatures, or any data structure:
type Point = {
x: number;
y: number;
};
const point: Point = { x: 10, y: 20 };
Custom types become even more useful when combined 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;
In larger codebases, well-named custom types also serve as documentation. When a function signature says Point instead of { x: number; y: number }, readers immediately know what the data represents. Convex's best practices for TypeScript shows how this pays off in database applications.
Working with Union and Intersection Types
The | and & operators let you compose types in two very different ways. Think of them as "or" and "and."
Union types (|) say a value can be one of several types. You'll use them constantly:
type StringOrNumber = string | number;
function processInput(input: StringOrNumber) {
if (typeof input === 'string') {
return input.toUpperCase();
} else {
return input.toFixed(2);
}
}
Intersection types (&) combine multiple types into one. Every property from both sides is required:
type Point2D = {
x: number;
y: number;
};
type Point3D = Point2D & { z: number };
const point: Point3D = { x: 10, y: 20, z: 30 };
That's the clean way to extend a type without touching the original. For complex data structures, discriminated union types take this further for modeling state transitions and API responses.
Unions also handle the success/failure pattern cleanly:
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, check out Convex's approach to argument validation which uses these techniques effectively.
Discriminated Unions: Modeling State with Precision
Discriminated unions are a pattern built on top of union types. The idea: every member of the union has a shared literal property (the "discriminant") that TypeScript uses to narrow types automatically.
A good example is a notification system where each notification type carries different data:
type EmailNotification = {
channel: 'email';
recipient: string;
subject: string;
body: string;
};
type PushNotification = {
channel: 'push';
deviceToken: string;
title: string;
badge?: number;
};
type SmsNotification = {
channel: 'sms';
phoneNumber: string;
message: string;
};
type Notification = EmailNotification | PushNotification | SmsNotification;
function dispatch(notification: Notification) {
switch (notification.channel) {
case 'email':
// TypeScript knows `subject` and `body` exist here
return sendEmail(notification.recipient, notification.subject, notification.body);
case 'push':
// TypeScript knows `deviceToken` and `badge` exist here
return sendPush(notification.deviceToken, notification.title, notification.badge);
case 'sms':
return sendSms(notification.phoneNumber, notification.message);
}
}
You can't accidentally pass phoneNumber to the email branch, or subject to the SMS branch. TypeScript enforces it. Add a new channel type later, and the switch statement will produce a compile error until you handle it.
For a deeper look at the never-based exhaustive checking pattern and more complex state machine examples, see the discriminated unions guide.
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:
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 shine when modeling domain objects or API responses. You can extend them 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:
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:
// 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, a paginated response type you can use everywhere:
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>;
This pattern shows up everywhere in real applications. Convex's type cookbook has more examples of building these shared types for data-heavy projects.
Type vs. Interface: A Practical Decision Guide
The short answer: default to type. Reach for interface when you have a specific reason.
The moment that makes the difference clearest is when you hit a type operation that interface simply can't express:
// These are type-only — interfaces can't do any of this
type UserId = string | number; // union
type ApiRoute = `/api/${string}`; // template literal
type IsString<T> = T extends string ? true : false; // conditional
type ReadonlyUser<T> = { readonly [K in keyof T]: T[K] }; // mapped
interface earns its place in two specific scenarios. First, when a class needs to implement a contract:
interface Logger {
log(message: string, level: 'info' | 'warn' | 'error'): void;
flush(): Promise<void>;
}
class FileLogger implements Logger {
log(message: string, level: 'info' | 'warn' | 'error') { /* ... */ }
flush() { return Promise.resolve(); }
}
Second, when you need declaration merging to extend third-party types (adding properties to the global Window object, for instance).
There are also some subtle behavioral differences between the two, including how they interact with Record<string, unknown> and how the TypeScript compiler caches them internally. The interface vs. type guide covers those edge cases in full.
Using Generics in TypeScript for Flexible Function Definitions
Generics let you write one function or class that works with any type, without giving up type safety. You've already seen them: Array<T>, Promise<T>, Partial<T>. Here's how to write your own:
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:
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.
The same idea applies to async data fetching. You write the fetch logic once and tell TypeScript what shape to expect:
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
Without strictNullChecks, TypeScript lets null and undefined slip through everywhere. With it enabled, you have to prove a value exists before using it:
// 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 {
if (text === null || text === undefined) {
return 0;
}
return text.length; // Safe to use now
}
Enable strictNullChecks in your tsconfig.json and never look back. Optional chaining and nullish coalescing make the required checks painless:
interface User {
name: string;
email?: string; // Optional property
profile: {
avatar?: string; // Nested optional property
};
}
function getUserEmail(user: User): string {
// Nullish coalescing 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";
}
You can push this further by combining nullable types with discriminated unions to represent every possible state explicitly:
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.
unknown vs any: Know the Difference
Both any and unknown tell TypeScript you're not sure about the type, but they behave very differently in practice.
any disables type checking entirely. TypeScript won't complain, but you lose all safety:
// Don't do this
function parseConfig(data: any) {
return data.settings.apiKey; // No error, even if data is null
}
unknown is the safer alternative. It forces you to check the type before using the value:
function parseConfig(data: unknown) {
// Must narrow the type before accessing properties
if (
typeof data === 'object' &&
data !== null &&
'settings' in data &&
typeof (data as any).settings?.apiKey === 'string'
) {
return (data as { settings: { apiKey: string } }).settings.apiKey;
}
throw new Error('Invalid config shape');
}
A practical rule: use unknown whenever you're receiving data from outside your system (API calls, JSON.parse, user input) and use any only as a last resort when migrating legacy code. If you find yourself reaching for any frequently, it's usually a sign the types need more thought.
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:
// Original type
interface User {
id: string;
name: string;
email: string;
password: string;
isAdmin: boolean;
}
// Make all properties optional — useful for update payloads
type PartialUser = Partial<User>;
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 — great for login forms
type UserCredentials = Pick<User, "email" | "password">;
const credentials: UserCredentials = {
email: "user@example.com",
password: "secure_password"
};
// Omit specific properties — useful for public-facing data
type PublicUser = Omit<User, "password" | "id">;
const publicProfile: PublicUser = {
name: "John Smith",
email: "john@example.com",
isAdmin: false
};
These four cover most situations. You'll also reach for Record<K, V> for key-value maps, Required<T> to strip optionality, and Exclude<T, U> to remove members from a union.
Utility types also help when your database schema and frontend types use different naming conventions:
// 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>;
Working with Complex Type Guards
Once you have a union type, you need a way to safely access properties that only exist on one member. Type guards do that:
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 using the is keyword:
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}`);
}
Type predicates like shape is Circle are especially useful when you're working with the same narrowing check in multiple places. You write the logic once and reuse it as a properly typed function.
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[];
};
Where Developers Get Stuck
A few patterns that trip people up when working with TypeScript types:
Forgetting to narrow before accessing union members. If you have type Shape = Circle | Rectangle, you can't access shape.radius directly. You need a type guard or discriminant check first.
Using any instead of unknown for external data. When you receive data from an API or JSON.parse, reach for unknown and validate it. any silently turns off the type checker for everything downstream.
Missing exhaustive checks in discriminated unions. If you add a new variant to a union and have switch statements throughout your code, TypeScript won't always warn you unless you've added an explicit never check. Use the assertNever pattern shown earlier.
Extending interfaces vs. intersecting types. Both interface B extends A and type B = A & { ... } combine shapes, but they're not identical. Interface extension catches property conflicts at definition time; intersection types may silently produce never for conflicting property types.
Final Thoughts on TypeScript Types
TypeScript's type system catches errors early and clarifies code intent. Start with type for most definitions, reach for interface when you need class contracts or extensible library types, and use discriminated unions whenever a value can be one of several distinct shapes.
A few rules worth keeping in mind:
- Prefer
unknownoveranywhen dealing with external data - Use discriminated unions with
neverchecks to make new variants impossible to miss - Utility types (
Partial,Pick,Omit) reduce duplication without sacrificing safety - Custom type guards are worth writing once the same narrowing logic appears in multiple places
The payoff compounds as your app grows. Types that feel like overhead on day one become the thing that lets you refactor confidently on day one hundred.