Core Concepts
Type Safety
Master TypeScript patterns that prevent runtime errors, catch bugs at compile time, and make your code self-documenting.
Already know TypeScript?
Why type safety matters
Writing TypeScript without proper type safety still allows runtime errors:
- ID mix-ups — Passing
userIdwhereorderIdexpected causes data corruption - Null errors — Accessing undefined values crashes production
- Any escapes — Using
anydefeats TypeScript's purpose - Type assertions — Blind
ascasts hide real type issues - Runtime failures — Errors only discovered when code runs
OneApp uses strict TypeScript with branded types (ID safety), AsyncResult<T> (error handling), API response types
(consistency), type guards (runtime safety), and zero any tolerance — catching errors before code even runs.
Use cases
Type safety enables:
- Prevent ID confusion — Never pass the wrong ID type to a function
- Safe null handling — Compiler enforces null checks before access
- Self-documenting code — Types serve as inline documentation
- Fearless refactoring — Compiler catches breaking changes
- Better IDE experience — Accurate autocomplete and inline docs
TypeScript configuration
Base configuration
All packages extend shared TypeScript configs:
{
"extends": "@repo/config/typescript/react.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"]
}Available configs
| Config | Use Case | Key Features |
|---|---|---|
typescript/react.json | React packages and apps | JSX support, DOM types |
typescript/nextjs.json | Next.js applications | Next.js types, incremental |
typescript/node.json | Node.js packages | Node types, CommonJS |
typescript/react-native.json | React Native/Expo | React Native types |
Strict mode enabled
All configs enable maximum strictness:
{
"compilerOptions": {
"strict": true, // All strict checks
"noUncheckedIndexedAccess": true, // arr[0] returns T | undefined
"exactOptionalPropertyTypes": true, // Can't assign undefined to optional
"noImplicitOverride": true, // Require 'override' keyword
"noPropertyAccessFromIndexSignature": true // Use bracket notation for index signatures
}
}Branded types
Prevent ID type confusion at compile time
The problem
// Without branded types - easy to mix up!
function getUser(userId: string) { ... }
function getOrder(orderId: string) { ... }
const userId = "user_123";
const orderId = "order_456";
getUser(orderId); // ❌ No error, but wrong!The solution
import { Brand } from "@repo/types";
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
function getUser(userId: UserId) { ... }
function getOrder(orderId: OrderId) { ... }
const userId = "user_123" as UserId;
const orderId = "order_456" as OrderId;
getUser(orderId); // ✅ Compiler error: Type 'OrderId' is not assignable to 'UserId'Creating branded types
export type Brand<T, Brand> = T & { __brand: Brand };
// Define your branded types
export type UserId = Brand<string, "UserId">;
export type ProductId = Brand<string, "ProductId">;
export type CategoryId = Brand<string, "CategoryId">;
export type Price = Brand<number, "Price">;
// Create type-safe factory functions
export function createUserId(id: string): UserId {
if (!id.startsWith("user_")) {
throw new Error("Invalid user ID format");
}
return id as UserId;
}
export function createPrice(amount: number): Price {
if (amount < 0) {
throw new Error("Price cannot be negative");
}
return amount as Price;
}Usage in applications
import type { UserId } from "@repo/types";
import { createUserId } from "@repo/types";
async function getUser(id: UserId) {
return await db.users.findUnique({ where: { id } });
}
// ✅ Type-safe usage
const id = createUserId("user_123");
await getUser(id);
// ❌ Compiler error
const rawId = "user_123";
await getUser(rawId); // Error: Argument of type 'string' is not assignable to 'UserId'AsyncResult pattern
Type-safe async operations without try/catch everywhere:
The pattern
export type AsyncResult<T> = { success: true; data: T } | { success: false; error: Error };Implementation
import type { AsyncResult, UserId } from "@repo/types";
async function fetchUser(id: UserId): Promise<AsyncResult<User>> {
try {
const user = await db.users.findUnique({ where: { id } });
if (!user) {
return {
success: false,
error: new Error("User not found")
};
}
return { success: true, data: user };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error : new Error("Unknown error")
};
}
}Type-safe consumption
export default async function UserPage({ params }: { params: { id: string } }) {
const userId = createUserId(params.id);
const result = await fetchUser(userId);
if (!result.success) {
// TypeScript knows result.error is Error
return <div>Error: {result.error.message}</div>;
}
// TypeScript knows result.data is User
return (
<div>
<h1>{result.data.name}</h1>
<p>{result.data.email}</p>
</div>
);
}Benefits:
- ✅ No unhandled promise rejections
- ✅ Explicit error handling
- ✅ Type-safe success and error paths
- ✅ No try/catch boilerplate
Type guards
Runtime type checking with compile-time benefits:
Creating type guards
// Type guard function
export function isUser(value: unknown): value is User {
return (
typeof value === "object" && value !== null && "id" in value && "email" in value && typeof value.email === "string"
);
}
export function isUserId(value: unknown): value is UserId {
return typeof value === "string" && value.startsWith("user_");
}
// Generic array type guard
export function isArrayOf<T>(value: unknown, guard: (item: unknown) => item is T): value is T[] {
return Array.isArray(value) && value.every(guard);
}Usage
import { isUser, isArrayOf } from "@repo/types";
async function processData(data: unknown) {
if (isUser(data)) {
// TypeScript knows data is User
console.log(data.email);
console.log(data.id);
}
if (isArrayOf(data, isUser)) {
// TypeScript knows data is User[]
data.forEach((user) => console.log(user.email));
}
}Best practices
1. Avoid any
// ❌ Bad - defeats TypeScript
function process(data: any) {
return data.value; // No type safety
}
// ✅ Good - use unknown with type guard
function process(data: unknown) {
if (isValidData(data)) {
return data.value; // Type-safe
}
throw new Error("Invalid data");
}2. Explicit return types
// ❌ Bad - inferred return type
function getUser(id: string) {
return db.users.findUnique({ where: { id } });
}
// ✅ Good - explicit return type
function getUser(id: UserId): Promise<User | null> {
return db.users.findUnique({ where: { id } });
}3. Use satisfies for type checking
// ✅ Validates type while preserving literal types
const config = {
port: 3000,
host: "localhost",
ssl: true
} satisfies ServerConfig;
// config.port is type 3000 (literal), not number4. Readonly for immutability
// ✅ Prevent accidental mutations
type Config = Readonly<{
apiUrl: string;
timeout: number;
}>;
const config: Config = {
apiUrl: "https://api.example.com",
timeout: 5000
};
config.apiUrl = "https://other.com"; // ❌ Error: Cannot assign to 'apiUrl'5. Discriminated unions
// ✅ Type-safe state management
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function handleState<T>(state: RequestState<T>) {
switch (state.status) {
case "idle":
return "Waiting...";
case "loading":
return "Loading...";
case "success":
return state.data; // TypeScript knows data exists
case "error":
return state.error.message; // TypeScript knows error exists
}
}Next steps
- Workspace architecture: Workspace Architecture →
- Dependency management: Dependency Management →
- Explore packages: Shared Packages →