TypeScript Best Practices for Send.dev

This document outlines TypeScript best practices learned from deployment issues and development experience.

Core Principles

1. Always Use Explicit Interfaces

❌ Avoid Complex Conditional Types

// This confuses TypeScript inference
const [editingDomain, setEditingDomain] = useState<typeof domains extends Array<infer T> ? T | null : null>(null)

✅ Define Explicit Interfaces

// Based on Convex schema
interface Domain {
  _id: Id<"loc_domains">
  locationId: Id<"loc_locations">
  domain: string
  description?: string
  dnsServer?: string
  isActive?: boolean
  createdBy: Id<"users">
  createdAt: number
  updatedAt: number
}

const [editingDomain, setEditingDomain] = useState<Domain | null>(null)

2. Define Interfaces from Convex Schema

Always create TypeScript interfaces that match your Convex schema definitions:

// From schema.ts:
// loc_domains: defineTable({
//   locationId: v.id("loc_locations"),
//   domain: v.string(),
//   description: v.optional(v.string()),
//   ...
// })

// Create matching interface:
interface Domain {
  _id: Id<"loc_domains">
  locationId: Id<"loc_locations">
  domain: string
  description?: string  // v.optional() becomes ?
  // ... other fields
}

3. Avoid any Types

❌ Don't Use any

const handleClick = (item: any) => {
  console.log(item._id) // No type safety
}

domains.map((domain: any) => domain.name)

✅ Use Proper Types

const handleClick = (item: Domain) => {
  console.log(item._id) // Type safe
}

domains.map((domain: Domain) => domain.name)

4. Local TypeScript Testing

Always test TypeScript compilation before deploying:

# Test specific app
npx tsc --noEmit --project apps/[app-name]/tsconfig.json

# Test entire project
npx tsc --noEmit

Common Patterns

1. Query Result Typing

// Convex query results
const domains = useQuery(api.loc_domains.listByLocation, {...})

// Type the results properly
const selectedDomain = domains?.find((d: Domain) => d._id === selectedDomainId)

2. Form State Management

interface DomainFormData {
  domain: string
  description: string
  dnsServer: string
  isActive: boolean
}

const [domainForm, setDomainForm] = useState<DomainFormData>({
  domain: "",
  description: "",
  dnsServer: "10.3.3.3",
  isActive: true,
})

3. Event Handlers

// Type event handlers properly
const handleEdit = (domain: Domain) => {
  setEditingDomain(domain)
  // ...
}

// In JSX
{domains.map((domain: Domain) => (
  <div key={domain._id} onClick={() => handleEdit(domain)}>
    {domain.domain}
  </div>
))}

4. Safe Array Access

// When you've verified array length
if (domains && domains.length > 0) {
  setSelectedDomainId(domains[0]!._id) // Non-null assertion is safe here
}

// Alternative: optional chaining
const firstDomain = domains?.[0]
if (firstDomain) {
  setSelectedDomainId(firstDomain._id)
}

Deployment Considerations

Vercel TypeScript Checks

Vercel runs strict TypeScript compilation during build. Common failures:

  1. Conditional type inference failures

    • Solution: Use explicit interfaces
  2. any type issues

    • Solution: Define proper interfaces
  3. Undefined object access

    • Solution: Use optional chaining or non-null assertions when safe

Error Examples and Fixes

Error: Property '_id' does not exist on type 'never'

// Problem: Complex conditional type
typeof domains extends Array<infer T> ? T | null : null

// Solution: Explicit interface
Domain | null

Error: Object is possibly 'undefined'

// Problem: Array access without verification
domains[0]._id

// Solution: Verify length first
if (domains && domains.length > 0) {
  domains[0]!._id  // Safe non-null assertion
}

Convex-Specific Patterns

1. Mutation Parameters

// Type mutation parameters
const updateDomain = useMutation(api.loc_domains.update)

const handleUpdate = async (domain: Domain) => {
  await updateDomain({
    id: domain._id,
    domain: formData.domain,
    isActive: formData.isActive,
  })
}

2. Query Filters

// When using filter instead of withIndex
const existing = await ctx.db
  .query("users")
  .filter(q => q.eq(q.field("custId"), custId))
  .first()

Testing Strategy

  1. Local compilation: Always test before pushing
  2. Incremental fixes: Fix all TypeScript errors in one comprehensive commit
  3. Interface-first approach: Define interfaces before implementing components
  4. Schema synchronization: Keep interfaces in sync with Convex schema changes

Summary

  • Define explicit interfaces based on Convex schema
  • Avoid complex conditional types that confuse TypeScript
  • Replace all any types with proper interfaces
  • Test TypeScript compilation locally before deploying
  • Use non-null assertions only when safe (after length checks)
  • Keep interfaces synchronized with schema changes

On this page