OneApp Docs
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 userId where orderId expected causes data corruption
  • Null errors — Accessing undefined values crashes production
  • Any escapes — Using any defeats TypeScript's purpose
  • Type assertions — Blind as casts 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:

packages/ui/tsconfig.json
{
  "extends": "@repo/config/typescript/react.json",
  "compilerOptions": {
    "outDir": "dist"
  },
  "include": ["src"]
}

Available configs

ConfigUse CaseKey Features
typescript/react.jsonReact packages and appsJSX support, DOM types
typescript/nextjs.jsonNext.js applicationsNext.js types, incremental
typescript/node.jsonNode.js packagesNode types, CommonJS
typescript/react-native.jsonReact Native/ExpoReact Native types

Strict mode enabled

All configs enable maximum strictness:

@repo/config/typescript/base.json
{
  "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

packages/types/src/brands.ts
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

apps/web/lib/users.ts
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

packages/types/src/async-result.ts
export type AsyncResult<T> = { success: true; data: T } | { success: false; error: Error };

Implementation

apps/web/lib/users.ts
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

apps/web/app/users/[id]/page.tsx
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

packages/types/src/guards.ts
// 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

apps/web/lib/validate.ts
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 number

4. 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

On this page