mutav-finance

convex-document-types

Use when defining or deriving TypeScript types from Convex schema tables, including Doc<>, Id<>, field access, NonNullable<>, array element types, discriminated union schema patterns, and choosing between filter and composite indexes

mutav-finance 0 Updated 4mo ago
GitHub

Install

npx skillscat add mutav-finance/mutav-app/convex-document-types

Install via the SkillsCat registry.

SKILL.md

Convex Document Types

Derive all document and field types from the Convex schema. Never define types manually.

Project structure

The Convex backend lives at the repo root in convex/, shared by every persona app in the Turborepo (apps/agency, apps/pay, apps/fund, apps/admin) — it is not nested inside any app. Imports inside convex/ use relative paths (the @ alias is not available in the Convex module resolver). Client code in an app (apps/<app>/src/) imports generated types via the @convex/... alias (e.g. @convex/_generated/api).

convex/
├── _generated/         # Auto-generated by Convex codegen — never edit
├── schema.ts           # Schema definition
├── contracts.ts        # Use cases (queries/mutations)
└── lib/                # Domain types, value objects, validators

As the backend grows, organize by domain. Each domain folder keeps Doc<>/Id<>/value-objects/validators co-located:

convex/
├── contracts/
│   ├── domain.ts       # Contract, ContractId, value objects, validators
│   └── useCases.ts     # queries/mutations
└── lib/

Deriving Document Types

// Inside convex/contracts/domain.ts
import type { Doc, Id } from "../_generated/dataModel";

export type Contract = Doc<"contracts">;
export type ContractId = Id<"contracts">;

export type ContractHistory = Doc<"contractHistory">;
export type ContractHistoryId = Id<"contractHistory">;

Deriving Field Types

Use indexed access to extract specific field types:

type ContractStatus = Contract["status"];
// Result: 'ativo' | 'encerrado' | 'pendente' | 'cancelado'

type PropertyKind = Contract["rental"]["propertyKind"];
// Result: 'residencial' | 'comercial'

Optional Field Types

Optional fields are typed as T | undefined. Use NonNullable<> to unwrap:

type TermApprovedAt = NonNullable<Contract["tenant"]["termApprovedAt"]>;
// Result: string  (was string | null)

Nested Object Types

type ContractRental = Contract["rental"];
type ContractTenant = Contract["tenant"];

// Type narrowing in code
if (contract.tenant.approvalStatus === "aprovado") {
  // contract.tenant.termApprovedAt is still string | null at this layer
}

Array Element Types

type ContractDocument = Contract["documents"][number];
// Result: { key: 'rentalContract' | 'inspection' | 'policy'; status: 'pendente' | 'enviado' | 'aprovado' }

Discriminated Union Extraction

Extract variants from discriminated unions using Extract<>:

type PendingContract = Extract<Contract, { status: "pendente" }>;

function isActiveContract(contract: Contract): contract is Extract<Contract, { status: "ativo" }> {
  return contract.status === "ativo";
}

Value Objects

For constants that need runtime access and type safety:

export const CONTRACT_STATUS = {
  ATIVO: "ativo",
  ENCERRADO: "encerrado",
  PENDENTE: "pendente",
  CANCELADO: "cancelado",
} as const satisfies Record<Uppercase<ContractStatus>, ContractStatus>;

as const is a narrowing assertion, not a type cast — it's permitted. as Type is prohibited.

Convex Argument Validators

Convex argument validators (v.union(), v.literal(), v.object()) used in mutation/query args definitions belong in the entity file alongside the value objects they derive from. Never create separate validators.ts files.

// convex/contracts/domain.ts
import { v } from "convex/values";
import type { Doc, Id } from "../_generated/dataModel";

export type Contract = Doc<"contracts">;
export type ContractStatus = Contract["status"];

export const CONTRACT_STATUS = {
  ATIVO: "ativo",
  ENCERRADO: "encerrado",
  PENDENTE: "pendente",
  CANCELADO: "cancelado",
} as const satisfies Record<Uppercase<ContractStatus>, ContractStatus>;

export const contractStatusValidator = v.union(
  v.literal(CONTRACT_STATUS.ATIVO),
  v.literal(CONTRACT_STATUS.ENCERRADO),
  v.literal(CONTRACT_STATUS.PENDENTE),
  v.literal(CONTRACT_STATUS.CANCELADO),
);

Then in useCases.ts:

import { contractStatusValidator } from "./domain";

export const updateStatus = mutation({
  args: { contractId: v.id("contracts"), status: contractStatusValidator },
  handler: async (ctx, args) => { /* ... */ },
});

Gotcha: schema.ts circular dependency. schema.ts CANNOT import from entity files because entity files import Doc<> from _generated/dataModel.d.ts which is generated from schema.ts. Validators used in schema.ts must be defined locally. Entity-file validators are for useCases.ts and other consumers, not for schema.ts.

Schema Discriminated Union Patterns

Tables with lifecycle states or type variants MUST use discriminated unions. Never use v.optional() or v.nullable() as placeholders for variant-specific data.

Pattern 1: Self-Discriminated Metadata

Stable top-level columns + a metadata bag with its own internal discriminator. The metadata discriminator must be an independent axis — never duplicate a top-level discriminator inside the metadata bag.

defineTable({
  status: v.union(v.literal("pendente"), v.literal("ativo"), v.literal("encerrado")),
  contractId: v.id("contracts"),
  metadata: v.union(
    v.object({ recipientType: v.literal("PF"), cpf: v.string() }),
    v.object({ recipientType: v.literal("PJ"), cnpj: v.string() }),
  ),
})

Narrowing on metadata.recipientType narrows the rest of the metadata. Top-level status and metadata.recipientType narrow independently.

Pattern 2: Two Self-Discriminated Bags

For tables with two independent axes (e.g. type + status):

defineTable({
  type: v.union(v.literal("setup"), v.literal("renewal")),
  status: v.union(v.literal("pendente"), v.literal("concluido")),
  typeData: v.union(
    v.object({ type: v.literal("setup"), installments: v.number() }),
    v.object({ type: v.literal("renewal"), originalContractId: v.id("contracts") }),
  ),
  metadata: v.union(
    v.object({ status: v.literal("pendente") }),
    v.object({ status: v.literal("concluido"), completedAt: v.string() }),
  ),
})

Pattern 3: Table-Level Union

defineTable(v.union(...)) where ALL variants have the SAME column names. Object field shapes change per variant:

defineTable(
  v.union(
    v.object({
      status: v.literal("pendente"),
      contractId: v.id("contracts"),
      metadata: v.object({}),
    }),
    v.object({
      status: v.literal("falhou"),
      contractId: v.id("contracts"),
      metadata: v.object({ error: v.string(), failedAt: v.string() }),
    }),
    v.object({
      status: v.literal("concluido"),
      contractId: v.id("contracts"),
      metadata: v.object({ completedAt: v.string(), txHash: v.string() }),
    }),
  ),
);

Pattern 4: Multi-Axis Spread

For tables with 3+ discrimination axes (e.g. provider × direction × status). Define reusable field groups per axis and spread them into v.union() variants. Each variant carries axis-specific validators.

const onRampBase = { provider: v.literal("avenia"), direction: v.literal("on_ramp"), sourceAmount: brlAmount };
const nullFields = { quoteId: v.null(), targetAmount: v.null() };
const onRampRequired = { quoteId: v.string(), targetAmount: brlaAmount };

defineTable(v.union(
  v.object({ ...onRampBase, ...nullFields,     metadata: meta.creating, status: v.literal("creating") }),
  v.object({ ...onRampBase, ...onRampRequired, metadata: meta.pending,  status: v.literal("pending") }),
));

Narrowing on any axis narrows all related fields — no type casts needed at provider boundaries.

Hard Rules

  • No v.optional() as placeholders for fields that don't apply to a variant.
  • v.null() and v.nullable() are NOT placeholders — but they have legitimate uses:
    • v.null() when a field structurally exists in the variant but data is genuinely unavailable (e.g., targetAmount: v.null() in creating status before a quote returns).
    • v.nullable() when data may or may not exist depending on the lifecycle path (e.g., externalId: v.nullable(v.string()) in a falhou status, since failure can occur before or after an external ID is assigned).
  • Top-level columns must be identical across ALL variants. Variant-specific data lives inside object fields (bags). Never create top-level columns that exist only in some variants — they become ghost/unset fields.
  • When migrating, delete stale documents before deploying schema changes. Never widen types with v.union(v.string(), v.null()) workarounds.
  • ctx.db.patch() with discriminated unions: patching requires metadata to resolve to a single variant — not a union. Always narrow by ALL discriminant axes before calling ctx.db.patch() with spread metadata. Never use as never to bypass PatchValue type checks.

Deriving Types from Discriminated Schemas

// For table-level unions (Pattern 3):
type FailedJob = Extract<Job, { status: "falhou" }>;
// FailedJob["metadata"] is { error: string; failedAt: string }

// For self-discriminated metadata (Pattern 1):
type JobMetadata = Job["metadata"];
type PJMetadata = Extract<JobMetadata, { recipientType: "PJ" }>;

PII fields: hash + envelope

Any new field that stores personal data — CPF, CNPJ, full name, email, phone, birth date, bank account number, account holder name, address — uses the hash + envelope pattern from day one. Never add a v.string() PII column.

The pattern stores two columns per logical field:

  • fieldHash: v.string() — HMAC-SHA256 of the plaintext, hex-encoded. Indexed when equality lookups are needed. Computed via hashPii(value) from convex/lib/pii.ts.
  • fieldEncrypted: encryptedEnvelopeValidator — AES-256-GCM envelope { ciphertext, iv, authTag }. Computed via encryptPii(value) and read back via decryptPii(envelope).
import { encryptedEnvelopeValidator } from "../lib/pii";

defineTable({
  agencyId: v.id("agencies"),
  // Logical field: CPF
  cpfHash: v.string(),
  cpfEncrypted: encryptedEnvelopeValidator,
  // … other fields
}).index("by_cpfHash", ["cpfHash"]);

When a table stores ONLY the hash (no decrypt path needed — e.g. claimedDocuments), drop the *Encrypted column entirely. The hash is the value.

defineTable({
  documentHash: v.string(), // hashPii(cpf | cnpj) — no plaintext
  agencyId: v.id("agencies"),
  claimedAt: v.string(),
}).index("by_documentHash", ["documentHash"]);

Writes always compute both columns in the same mutation:

const cpfHash = await hashPii(cpf);
const cpfEncrypted = await encryptPii(cpf);
await ctx.db.insert("...", { ..., cpfHash, cpfEncrypted });

Reads decrypt only at presentation/export boundaries (later phases will audit-log each decrypt). Equality lookups go through the hash index, never a plaintext column. The full pattern rationale (two-key separation, WebCrypto choice, rotation story) lives in `docs/architecture/decisions/0001-pii-crypto-pattern.md`.

Index vs Filter Decision

Before using .filter() after .withIndex(), evaluate whether a composite index is better. .filter() runs in-memory after loading all index-matched documents.

Question .filter() OK Composite index needed
Does the table grow with user activity? No (< 100 docs) Yes (orders, transactions, history, attempts)
How selective is the filter? Keeps most docs Keeps few docs (e.g. 3 of 500)
How many indexes does the table have? Near 32 limit Room to add

Tradeoffs of adding a composite index:

  • Benefit: Documents scanned drops from all index matches to only matching docs
  • Cost: +1 index (Convex limit: 32/table), marginally slower writes, more storage
  • Rule of thumb: if the table grows with user activity and the filter discards most documents, always add the composite index — one-line schema change with outsized read performance impact
// BAD on growing tables — reads all history for contract, filters in memory
ctx.db.query("contractHistory")
  .withIndex("by_contract", (q) => q.eq("contractPublicId", id))
  .filter((q) => q.eq(q.field("username"), "system"))
  .collect();

// GOOD — composite index pushes both conditions to storage
// schema: .index("by_contract_user", ["contractPublicId", "username"])
ctx.db.query("contractHistory")
  .withIndex("by_contract_user", (q) =>
    q.eq("contractPublicId", id).eq("username", "system"),
  )
  .collect();

Best Practices

  • Always import from _generated/dataModel — never define document types manually
  • Use Doc<'tableName'> for complete document types only inside the entity/domain file that defines the document type for that table; export a type alias (e.g. export type Contract = Doc<'contracts'>). Outside entity files, never use raw Doc<'tableName'>; always use the exported type alias.
  • Use Id<'tableName'> for document ID types only inside the entity/domain file; export a type alias (e.g. ContractId = Contract['_id']). Outside entity files, always use the exported type alias.
  • Derive field types using indexed access (e.g., Contract['status'])
  • Co-locate type exports with domain entities in convex/{domain}/domain.ts
  • Co-locate Convex argument validators in entity files next to the value objects they derive from — never create separate validators.ts files
  • Use NonNullable<T> when extracting optional nested types
  • Use Extract<> to narrow discriminated union variants
  • Use as const satisfies for value objects

See also: convex-functional-programming, official convex-quickstart, convex-migration-helper

Categories