Skip to main content

Documentation Index

Fetch the complete documentation index at: https://grantmaster.dev/llms.txt

Use this file to discover all available pages before exploring further.

TypeScript Type System

This document explains the core TypeScript patterns, utility types, and interfaces used across the GrantMaster codebase.

🏗️ Type Hierarchy Visual

🛠️ Core Utility Types

We use a set of global utility types to ensure consistency across services.

1. TenantScoped

Every document belonging to a tenant should inherit from this.
interface TenantScoped {
  organizationId: string;
  createdAt: Timestamp;
  updatedAt: Timestamp;
  createdBy: string;
}

2. WithId<T>

Adds an id field to a type (useful for Firestore docs where the ID is the document name).
type WithId<T> = T & { id: string };

3. AsyncResult<T>

Standardized response for async operations.
type AsyncResult<T> = {
  data: T | null;
  error: Error | null;
  loading: boolean;
};

📂 Type Directory Structure

  • src/schemas/: Canonical persisted entity schemas and inferred types.
  • src/features/{feature}/contracts.ts: Feature-owned domain contracts.
  • src/shared/*/*.contracts.ts and src/core/*.contracts.ts: Cross-feature and infrastructure contracts.
  • src/core/firestoreCollections.ts: Mapping of collection IDs to their TypeScript interfaces.
  • packages/shared/: Common utility types (Address, Attachment, BankAccount, etc.) shared with Cloud Functions.

🔄 Type Conversion & Mapping

We follow a strict “DTO to Entity” pattern in the service layer:
  1. Incoming (API/Scraper): Raw JSON or third-party types.
  2. Storage (Firestore): Flat, indexed documents.
  3. Domain (Service): Rich objects with derived fields (e.g., matchScore).
  4. Presentation (UI): Truncated or formatted strings for display.

🔄 DTO-to-Entity Flow (Full Example)

The type system enforces a four-layer boundary. Here is a concrete walk-through using the Expense domain:
External / API layer          Firestore layer           Service layer              UI layer
──────────────────────        ──────────────────        ─────────────────────      ─────────────────────
Raw form values               ExpenseDocument           Expense (domain type)      ExpenseRow (display)
  { amount: "50.00",    ──►   { amount: 50,       ──►  { amount: 50,         ──►  { amount: "€50.00",
    category: "travel" }        category: "travel",       category: "Travel",         category: "Travel",
                                organizationId,           organizationId,             statusBadge: "Pending",
                                createdAt: Timestamp,     createdAt: Date,            daysAgo: "2 days ago" }
                                status: "pending" }       status: "pending",
                                                          budgetLineLabel: "Travel" }

Layer responsibilities

Incoming (form / API): Raw string inputs validated by Zod schemas in src/schemas/. Zod coerces strings to numbers/dates and strips unknown fields before any service call. Storage (Firestore): Flat documents using Firestore Timestamp types. Stored in canonical snake_case in Firestore, mapped to camelCase TypeScript types by firestoreCollections.ts. Domain (service output): WithId<T> types with derived fields added by service methods (e.g., matchScore on GrantOpportunity, burnRate on BudgetForecast). Timestamps converted to Date. Presentation (UI): Display-only types in feature components/ — formatted strings, badge labels, relative times. Never passed back to services.

Discriminated Unions

Discriminated unions are used wherever an entity can be in fundamentally different shapes based on a type or status discriminant.

Example: SystemEvent<T>

// packages/shared/src/events.ts
type SystemEvent<T = unknown> =
  | { type: SystemEventType.EXPENSE_APPROVED;   payload: ExpenseApprovedPayload }
  | { type: SystemEventType.GRANT_WON;          payload: GrantWonPayload }
  | { type: SystemEventType.QUOTA_EXCEEDED;     payload: QuotaExceededPayload }
  | { type: SystemEventType.MODULE_INSTALLED;   payload: ModuleInstalledPayload };

// Pattern-matched in consumers:
function handleEvent(event: SystemEvent) {
  switch (event.type) {
    case SystemEventType.EXPENSE_APPROVED:
      // TypeScript narrows payload to ExpenseApprovedPayload here
      notifyFinanceTeam(event.payload.expenseId);
      break;
  }
}

Example: ServiceResult<T>

All service methods return ServiceResult<T> — a discriminated union of success and failure:
type ServiceResult<T> =
  | { success: true;  data: T;     error: null }
  | { success: false; data: null;  error: AppError };

// Usage in a component:
const result = await expenseService.create(orgId, expenseData);
if (!result.success) {
  toast.error(result.error.message);
  return;
}
// result.data is typed as Expense here

Example: NotificationPayload

type NotificationPayload =
  | { channel: 'email';    templateId: PostmarkTemplateId; to: string }
  | { channel: 'in-app';   userId: string;                 message: string }
  | { channel: 'sms';      phone: string;                  message: string };

Naming Conventions

Files

PatternConventionExample
Domain typescamelCase.tsgrants.ts, hr.ts
Zod schemascamelCase.schema.tsprojects.schema.ts, grants.schema.ts
Barrel exportsindex.tsAvoid root barrels; export from the owning module
Ambient declarations*.d.tsglobal.d.ts, firebase-functions.d.ts

Types and Interfaces

PatternConventionExample
Domain entityPascalCaseProject, ActiveGrant, JournalEntry
EnumPascalCaseProjectStatus, UserRole
Union string literal typePascalCasePipelineStage, BudgetCategory
Zod schema variablePascalCase + SchemaProjectSchema, ExpenseSchema
Inferred Zod typez.infer<typeof XSchema>exported from the owning schema or feature contract
DTO (input to a service)Create{Entity}DTO / Update{Entity}DTOCreateExpenseDTO, UpdateProjectDTO
Display/view model{Entity}Row / {Entity}CardExpenseRow, GrantCard

Firestore ↔ TypeScript naming

Firestore collection names use plural snake_case; TypeScript types use singular PascalCase. The mapping is declared in src/core/firestoreCollections.ts:
export const COLLECTIONS = {
  EMPLOYEES:       'employees',        // → User
  TIMESHEETS:      'timesheets',       // → JournalEntry
  RETROSPECTIVES:  'retrospectives',   // → JournalSubmission
  GRANT_PIPELINE:  'grantPipeline',    // → GrantPipelineEntry
  ACTIVE_GRANTS:   'activeGrants',     // → ActiveGrant
} as const;
Rule: If the collection name differs from what you’d expect from the entity name, the mapping must be documented both here and in the domain model naming gotchas table.

Schema Layer (src/schemas/)

Zod schemas in src/schemas/ are the canonical validation layer. Persisted entity types should be derived from schemas using z.infer<>, while non-persisted domain contracts should live beside the owning feature or shared/core module.
src/schemas/projects.schema.ts   →  defines ProjectSchema (Zod)
src/schemas/projects.schema.ts   →  export type Project = z.infer<typeof projectSchema>
This means: if you need to add a field, add it to the schema first. The TypeScript type updates automatically.

Schema validation in services

// Inside BaseService.create():
const parsed = Schema.safeParse(data);
if (!parsed.success) {
  return { success: false, data: null, error: new ValidationError(parsed.error) };
}
// parsed.data is the clean, coerced domain object
await setDoc(ref, parsed.data);

🛡️ Enforcing Type Safety

  • No any: The use of any is strictly prohibited. Use unknown or a generic if the type is truly dynamic.
  • Discriminated Unions: Used for all multi-shape types (events, service results, notification payloads).
  • Strict Null Checks: All optional fields must be explicitly marked with ? and handled in the UI.
  • Zod first: All external input (forms, API responses, Firestore reads via converters) passes through Zod before touching domain types.
  • WithId<T> discipline: Raw Firestore documents are typed as T; after reading and attaching the document ID, they become WithId<T>. Never mutate the source document to add id.