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
Install
npx skillscat add mutav-finance/mutav-app/convex-document-types Install via the SkillsCat registry.
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, validatorsAs 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.tsCANNOT import from entity files because entity files importDoc<>from_generated/dataModel.d.tswhich is generated fromschema.ts. Validators used inschema.tsmust be defined locally. Entity-file validators are foruseCases.tsand other consumers, not forschema.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()andv.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()increatingstatus 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 afalhoustatus, 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 callingctx.db.patch()with spread metadata. Never useas neverto 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 viahashPii(value)fromconvex/lib/pii.ts.fieldEncrypted: encryptedEnvelopeValidator— AES-256-GCM envelope{ ciphertext, iv, authTag }. Computed viaencryptPii(value)and read back viadecryptPii(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 rawDoc<'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.tsfiles - Use
NonNullable<T>when extracting optional nested types - Use
Extract<>to narrow discriminated union variants - Use
as const satisfiesfor value objects
See also: convex-functional-programming, official convex-quickstart, convex-migration-helper