Invalid Date

When integrating a PDF generation API into a TypeScript project, you often run into problems like "what shape does the API response have?" or "I misspelled a template variable and only found out in production." In JavaScript, these errors hide until runtime. TypeScript's type system catches them at compile time.

This guide uses FUNBREW PDF as the example API and walks through design patterns for type-safe PDF generation. For basic SDK usage, see the language quickstart guide. For HTML-to-PDF fundamentals, check the HTML to PDF complete guide.

Why TypeScript Matters for PDF APIs

PDF generation involves multiple data structures — HTML templates, rendering options, API responses, and error payloads. Without types, subtle bugs creep in:

Problem In JavaScript With TypeScript
Typo in option name Silently ignored at runtime Compile-time error
Missing template variable Incomplete PDF generated Type checker catches it
Unknown response shape Handled as any, runtime crash Full IDE autocompletion
Invalid engine name API returns 400 Union type prevents it

Types serve double duty: they prevent bugs and act as living documentation for your team.

SDK Type Definitions (@funbrew/pdf)

The FUNBREW PDF Node.js SDK is written in TypeScript and ships with type definitions included. No separate @types package needed.

npm install @funbrew/pdf

Here are the key types the SDK provides:

import { FunbrewPdf } from '@funbrew/pdf'

// Client initialization — apiKey must be a string
const client = new FunbrewPdf({
  apiKey: process.env.FUNBREW_PDF_API_KEY!,
})

// generate method argument types
interface GenerateOptions {
  html: string
  options?: {
    format?: 'A3' | 'A4' | 'A5' | 'Letter' | 'Legal'
    landscape?: boolean
    margin?: {
      top?: string
      bottom?: string
      left?: string
      right?: string
    }
    engine?: 'quality' | 'fast'
    headerHtml?: string
    footerHtml?: string
  }
}

The engine field is a union type 'quality' | 'fast' — pass anything else and the compiler rejects it. quality uses Chromium (high fidelity), fast uses wkhtmltopdf (speed optimized). For a deep comparison, see wkhtmltopdf vs Chromium.

Type-Safe API Responses

Even when calling the API directly without the SDK, defining types prevents mistakes:

// types/funbrew.ts

/** PDF generation request body */
interface PdfGenerateRequest {
  html: string
  options?: PdfOptions
}

interface PdfOptions {
  format?: 'A3' | 'A4' | 'A5' | 'Letter' | 'Legal'
  landscape?: boolean
  margin?: MarginOptions
  engine?: 'quality' | 'fast'
  headerHtml?: string
  footerHtml?: string
}

interface MarginOptions {
  top?: string
  bottom?: string
  left?: string
  right?: string
}

/** PDF generation error response */
interface PdfErrorResponse {
  error: string
  message?: string
  statusCode: number
}

/** Type-safe fetch wrapper */
async function generatePdf(request: PdfGenerateRequest): Promise<ArrayBuffer> {
  const response = await fetch('https://api.funbrew.dev/api/v1/generate', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.FUNBREW_PDF_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(request),
  })

  if (!response.ok) {
    const error: PdfErrorResponse = await response.json()
    throw new Error(`PDF generation error: ${error.message ?? error.error}`)
  }

  return response.arrayBuffer()
}

For comprehensive error handling patterns, see the error handling guide.

Generics for Type-Safe Templates

Template variable injection is where most PDF generation bugs occur. Generics let you enforce type safety on template builders:

// lib/pdf-template.ts

/** Base template type */
interface PdfTemplate<TData> {
  name: string
  build: (data: TData) => string
}

/** Invoice template data */
interface InvoiceData {
  invoiceNumber: string
  companyName: string
  customerName: string
  issueDate: string
  dueDate: string
  items: InvoiceItem[]
  taxRate: number
}

interface InvoiceItem {
  name: string
  quantity: number
  unitPrice: number
}

/** Invoice template implementation */
const invoiceTemplate: PdfTemplate<InvoiceData> = {
  name: 'invoice',
  build: (data) => {
    const subtotal = data.items.reduce(
      (sum, item) => sum + item.quantity * item.unitPrice, 0
    )
    const tax = Math.round(subtotal * data.taxRate * 100) / 100
    const total = subtotal + tax

    const rows = data.items
      .map(item => `
        <tr>
          <td>${item.name}</td>
          <td class="right">${item.quantity}</td>
          <td class="right">$${item.unitPrice.toFixed(2)}</td>
          <td class="right">$${(item.quantity * item.unitPrice).toFixed(2)}</td>
        </tr>
      `).join('')

    return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    body { font-family: sans-serif; padding: 40px; color: #333; }
    h1 { color: #1a56db; border-bottom: 2px solid #1a56db; padding-bottom: 10px; }
    table { width: 100%; border-collapse: collapse; margin: 20px 0; }
    th, td { border: 1px solid #e5e7eb; padding: 10px; }
    th { background: #f9fafb; text-align: left; }
    .right { text-align: right; }
    .summary { margin-top: 20px; text-align: right; }
    .total { font-size: 1.4em; font-weight: bold; color: #1a56db; }
  </style>
</head>
<body>
  <h1>Invoice #${data.invoiceNumber}</h1>
  <p>${data.companyName}</p>
  <p>Bill to: ${data.customerName}</p>
  <p>Issued: ${data.issueDate} / Due: ${data.dueDate}</p>
  <table>
    <thead>
      <tr><th>Item</th><th class="right">Qty</th><th class="right">Unit Price</th><th class="right">Subtotal</th></tr>
    </thead>
    <tbody>${rows}</tbody>
  </table>
  <div class="summary">
    <p>Subtotal: $${subtotal.toFixed(2)}</p>
    <p>Tax (${data.taxRate * 100}%): $${tax.toFixed(2)}</p>
    <p class="total">Total: $${total.toFixed(2)}</p>
  </div>
</body>
</html>`
  },
}

With this pattern, each template has a strongly typed data requirement. Missing a required field triggers a compile-time error. For template engine integrations, see the template engine guide.

Template Registry

When managing multiple templates, generics keep the registry fully typed:

// lib/pdf-registry.ts

type TemplateRegistry = {
  invoice: PdfTemplate<InvoiceData>
  certificate: PdfTemplate<CertificateData>
  report: PdfTemplate<ReportData>
}

/** Certificate template data */
interface CertificateData {
  recipientName: string
  courseName: string
  completionDate: string
  certificateId: string
}

/** Report template data */
interface ReportData {
  title: string
  author: string
  sections: Array<{ heading: string; content: string }>
  generatedAt: string
}

/** Type-safe PDF generation from template */
async function generateFromTemplate<K extends keyof TemplateRegistry>(
  client: FunbrewPdf,
  templateName: K,
  data: TemplateRegistry[K] extends PdfTemplate<infer D> ? D : never,
  options?: PdfOptions
): Promise<ArrayBuffer> {
  const template = templates[templateName]
  const html = template.build(data)
  return client.generate({ html, options })
}

// Usage — data type is automatically inferred
const pdf = await generateFromTemplate(client, 'invoice', {
  invoiceNumber: 'INV-2026-001',
  companyName: 'FUNBREW Inc.',
  customerName: 'Jane Smith',
  issueDate: '2026-04-04',
  dueDate: '2026-04-30',
  items: [
    { name: 'PDF API Standard Plan', quantity: 1, unitPrice: 49.00 },
    { name: 'Additional API Calls (1000)', quantity: 3, unitPrice: 9.00 },
  ],
  taxRate: 0.1,
})

For invoice automation patterns, see the invoice PDF automation guide. For certificate generation, see the certificate automation guide.

Zod Validation Integration

TypeScript types only exist at compile time. For runtime safety on API endpoints and webhooks, combine types with Zod schemas:

// lib/schemas/pdf.ts
import { z } from 'zod'

/** PDF options schema */
const pdfOptionsSchema = z.object({
  format: z.enum(['A3', 'A4', 'A5', 'Letter', 'Legal']).default('A4'),
  landscape: z.boolean().default(false),
  margin: z.object({
    top: z.string().default('20mm'),
    bottom: z.string().default('20mm'),
    left: z.string().default('15mm'),
    right: z.string().default('15mm'),
  }).optional(),
  engine: z.enum(['quality', 'fast']).default('quality'),
})

/** PDF generation request schema */
const pdfGenerateSchema = z.object({
  html: z.string().min(1, 'HTML is required').max(5_000_000, 'HTML too large'),
  options: pdfOptionsSchema.optional(),
  filename: z.string().regex(/^[\w\-\.]+\.pdf$/).optional(),
})

/** Derive types from schema — always in sync */
type PdfGenerateInput = z.infer<typeof pdfGenerateSchema>
type PdfOptions = z.infer<typeof pdfOptionsSchema>

export { pdfGenerateSchema, pdfOptionsSchema }
export type { PdfGenerateInput, PdfOptions }

The z.infer pattern keeps your schema and types permanently synchronized. Change the schema once and the type updates automatically — no duplicate definitions to maintain.

Invoice Data Validation

// lib/schemas/invoice.ts
import { z } from 'zod'

const invoiceItemSchema = z.object({
  name: z.string().min(1),
  quantity: z.number().int().positive(),
  unitPrice: z.number().nonnegative(),
})

const invoiceDataSchema = z.object({
  invoiceNumber: z.string().regex(/^INV-\d{4}-\d{3,}$/),
  companyName: z.string().min(1),
  customerName: z.string().min(1),
  issueDate: z.string().date(),
  dueDate: z.string().date(),
  items: z.array(invoiceItemSchema).min(1, 'At least one item is required'),
  taxRate: z.number().min(0).max(1),
})

type InvoiceData = z.infer<typeof invoiceDataSchema>

export { invoiceDataSchema }
export type { InvoiceData }

Practical: Next.js App Router

Combining Zod validation with the SDK in a Next.js API Route. For framework-specific basics, see the Next.js and Nuxt guide.

// app/api/generate-pdf/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { FunbrewPdf } from '@funbrew/pdf'
import { pdfGenerateSchema } from '@/lib/schemas/pdf'

const client = new FunbrewPdf({
  apiKey: process.env.FUNBREW_PDF_API_KEY!,
})

export async function POST(request: NextRequest) {
  // 1. Validate with Zod
  const body = await request.json()
  const result = pdfGenerateSchema.safeParse(body)

  if (!result.success) {
    return NextResponse.json(
      {
        error: 'Validation failed',
        details: result.error.flatten().fieldErrors,
      },
      { status: 400 }
    )
  }

  // 2. result.data is inferred as PdfGenerateInput
  const { html, options, filename = 'document.pdf' } = result.data

  try {
    const pdfBuffer = await client.generate({ html, options })

    return new NextResponse(pdfBuffer, {
      headers: {
        'Content-Type': 'application/pdf',
        'Content-Disposition': `attachment; filename="${filename}"`,
      },
    })
  } catch (error) {
    console.error('PDF generation error:', error)
    return NextResponse.json(
      { error: 'Failed to generate PDF' },
      { status: 500 }
    )
  }
}

Zod's safeParse returns detailed validation errors for client feedback. After validation, result.data is fully typed as PdfGenerateInput — the rest of your code is completely type-safe.

Practical: Express

Middleware pattern for Express + TypeScript:

// middleware/validate.ts
import { Request, Response, NextFunction } from 'express'
import { ZodSchema, ZodError } from 'zod'

/** Zod validation middleware */
function validate<T>(schema: ZodSchema<T>) {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.body)

    if (!result.success) {
      return res.status(400).json({
        error: 'Validation failed',
        details: (result.error as ZodError).flatten().fieldErrors,
      })
    }

    req.body = result.data
    next()
  }
}

export { validate }
// routes/pdf.ts
import { Router, Request, Response } from 'express'
import { FunbrewPdf } from '@funbrew/pdf'
import { pdfGenerateSchema, PdfGenerateInput } from '../lib/schemas/pdf'
import { validate } from '../middleware/validate'

const router = Router()
const client = new FunbrewPdf({
  apiKey: process.env.FUNBREW_PDF_API_KEY!,
})

router.post(
  '/generate',
  validate(pdfGenerateSchema),
  async (req: Request, res: Response) => {
    const { html, options, filename = 'document.pdf' } = req.body as PdfGenerateInput

    try {
      const pdfBuffer = await client.generate({ html, options })

      res.set({
        'Content-Type': 'application/pdf',
        'Content-Disposition': `attachment; filename="${filename}"`,
      })
      res.send(Buffer.from(pdfBuffer))
    } catch (error) {
      console.error('PDF generation error:', error)
      res.status(500).json({ error: 'Failed to generate PDF' })
    }
  }
)

export { router as pdfRouter }

Practical: Deno

Deno (2.x) runs TypeScript natively with zero configuration. npm packages work via the npm: prefix:

// server.ts (Deno)
import { FunbrewPdf } from 'npm:@funbrew/pdf'
import { z } from 'npm:zod'

const client = new FunbrewPdf({
  apiKey: Deno.env.get('FUNBREW_PDF_API_KEY')!,
})

const requestSchema = z.object({
  html: z.string().min(1),
  options: z.object({
    format: z.enum(['A3', 'A4', 'A5', 'Letter', 'Legal']).default('A4'),
    engine: z.enum(['quality', 'fast']).default('quality'),
  }).optional(),
})

Deno.serve({ port: 8000 }, async (req: Request): Promise<Response> => {
  if (req.method !== 'POST' || new URL(req.url).pathname !== '/generate-pdf') {
    return new Response('Not Found', { status: 404 })
  }

  const body = await req.json()
  const result = requestSchema.safeParse(body)

  if (!result.success) {
    return new Response(
      JSON.stringify({ error: 'Validation failed', details: result.error.flatten() }),
      { status: 400, headers: { 'Content-Type': 'application/json' } }
    )
  }

  try {
    const pdfBuffer = await client.generate({
      html: result.data.html,
      options: result.data.options,
    })

    return new Response(pdfBuffer, {
      headers: {
        'Content-Type': 'application/pdf',
        'Content-Disposition': 'attachment; filename="document.pdf"',
      },
    })
  } catch (error) {
    console.error('PDF generation error:', error)
    return new Response(
      JSON.stringify({ error: 'Failed to generate PDF' }),
      { status: 500, headers: { 'Content-Type': 'application/json' } }
    )
  }
})

console.log('Server running on http://localhost:8000')
# Run the server
deno run --allow-net --allow-env server.ts

Discriminated Unions for Error Handling

Representing PDF generation results as a Success/Failure union forces callers to handle both cases:

// lib/pdf-result.ts

type PdfResult =
  | { success: true; buffer: ArrayBuffer; metadata: PdfMetadata }
  | { success: false; error: PdfError }

interface PdfMetadata {
  sizeBytes: number
  pageCount: number
  engine: 'quality' | 'fast'
  generatedAt: Date
}

interface PdfError {
  code: 'VALIDATION_ERROR' | 'API_ERROR' | 'TIMEOUT' | 'RATE_LIMITED'
  message: string
  retryable: boolean
}

/** Type-safe PDF generation */
async function generatePdfSafe(
  client: FunbrewPdf,
  request: PdfGenerateInput
): Promise<PdfResult> {
  try {
    const buffer = await client.generate({
      html: request.html,
      options: request.options,
    })

    return {
      success: true,
      buffer,
      metadata: {
        sizeBytes: buffer.byteLength,
        pageCount: 1,
        engine: request.options?.engine ?? 'quality',
        generatedAt: new Date(),
      },
    }
  } catch (error) {
    const pdfError = classifyError(error)
    return { success: false, error: pdfError }
  }
}

function classifyError(error: unknown): PdfError {
  if (error instanceof Error) {
    if (error.message.includes('429')) {
      return { code: 'RATE_LIMITED', message: 'Rate limit exceeded', retryable: true }
    }
    if (error.message.includes('timeout')) {
      return { code: 'TIMEOUT', message: 'Request timed out', retryable: true }
    }
  }
  return { code: 'API_ERROR', message: String(error), retryable: false }
}

// Usage — TypeScript forces you to handle both branches
const result = await generatePdfSafe(client, { html: '<h1>Hello</h1>' })

if (result.success) {
  // result.buffer and result.metadata are accessible
  console.log(`PDF generated: ${result.metadata.sizeBytes} bytes`)
} else {
  // result.error is accessible
  if (result.error.retryable) {
    console.log('Retryable error:', result.error.message)
  }
}

Type-Safe Batch Generation

For generating multiple PDFs at once, combine generics with Promise.allSettled:

// lib/batch-pdf.ts

interface BatchItem<T> {
  id: string
  templateName: string
  data: T
  options?: PdfOptions
}

interface BatchResult {
  id: string
  status: 'fulfilled' | 'rejected'
  sizeBytes?: number
  error?: string
}

async function generateBatch<T>(
  client: FunbrewPdf,
  items: BatchItem<T>[],
  buildHtml: (data: T) => string,
  concurrency = 5
): Promise<BatchResult[]> {
  const results: BatchResult[] = []

  for (let i = 0; i < items.length; i += concurrency) {
    const chunk = items.slice(i, i + concurrency)
    const settled = await Promise.allSettled(
      chunk.map(async (item) => {
        const html = buildHtml(item.data)
        const buffer = await client.generate({ html, options: item.options })
        return { id: item.id, buffer }
      })
    )

    for (const [index, result] of settled.entries()) {
      if (result.status === 'fulfilled') {
        results.push({
          id: result.value.id,
          status: 'fulfilled',
          sizeBytes: result.value.buffer.byteLength,
        })
      } else {
        results.push({
          id: chunk[index].id,
          status: 'rejected',
          error: String(result.reason),
        })
      }
    }
  }

  return results
}

For high-volume PDF generation patterns, see the batch processing guide.

Markdown to PDF with TypeScript

FUNBREW PDF also supports direct Markdown-to-PDF conversion. Apply TypeScript types to the Markdown API as well:

// lib/markdown-pdf.ts
import { z } from 'zod'

const markdownThemes = [
  'default', 'github', 'academic', 'corporate', 'minimal'
] as const

const markdownPdfSchema = z.object({
  markdown: z.string().min(1, 'Markdown content is required'),
  theme: z.enum(markdownThemes).default('default'),
  options: z.object({
    format: z.enum(['A3', 'A4', 'A5', 'Letter', 'Legal']).default('A4'),
    margin: z.object({
      top: z.string().default('20mm'),
      bottom: z.string().default('20mm'),
      left: z.string().default('15mm'),
      right: z.string().default('15mm'),
    }).optional(),
  }).optional(),
})

type MarkdownPdfInput = z.infer<typeof markdownPdfSchema>
type MarkdownTheme = typeof markdownThemes[number]

/** Generate PDF from Markdown */
async function generateFromMarkdown(
  input: MarkdownPdfInput
): Promise<ArrayBuffer> {
  const validated = markdownPdfSchema.parse(input)

  const response = await fetch('https://api.funbrew.dev/api/pdf/generate-from-markdown', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.FUNBREW_PDF_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(validated),
  })

  if (!response.ok) {
    throw new Error(`Markdown PDF error: ${response.status}`)
  }

  return response.arrayBuffer()
}

export { markdownPdfSchema, generateFromMarkdown }
export type { MarkdownPdfInput, MarkdownTheme }

For the full Markdown-to-PDF workflow, see the Markdown to PDF API guide.

Production Patterns

Type-Safe Environment Variables

// lib/env.ts
import { z } from 'zod'

const envSchema = z.object({
  FUNBREW_PDF_API_KEY: z.string().startsWith('sk-'),
  PDF_TIMEOUT_MS: z.coerce.number().default(30000),
  PDF_MAX_RETRIES: z.coerce.number().default(3),
  PDF_DEFAULT_ENGINE: z.enum(['quality', 'fast']).default('quality'),
})

/** Validate at startup — catch missing config before first request */
export const env = envSchema.parse(process.env)

Validating environment variables at application startup prevents the "API key not set" surprise that only surfaces in production. For security best practices, see the security guide.

Type-Safe Retry Function

// lib/retry.ts

interface RetryOptions {
  maxRetries: number
  baseDelayMs: number
  maxDelayMs: number
}

async function withRetry<T>(
  fn: () => Promise<T>,
  options: RetryOptions = { maxRetries: 3, baseDelayMs: 1000, maxDelayMs: 10000 }
): Promise<T> {
  let lastError: Error | undefined

  for (let attempt = 1; attempt <= options.maxRetries; attempt++) {
    try {
      return await fn()
    } catch (error) {
      lastError = error as Error

      if (attempt < options.maxRetries) {
        const delay = Math.min(
          options.baseDelayMs * Math.pow(2, attempt - 1),
          options.maxDelayMs
        )
        await new Promise(resolve => setTimeout(resolve, delay))
      }
    }
  }

  throw lastError
}

// Usage
const pdfBuffer = await withRetry(
  () => client.generate({ html: '<h1>Hello</h1>', options: { format: 'A4' } }),
  { maxRetries: 3, baseDelayMs: 1000, maxDelayMs: 10000 }
)

For a comprehensive production checklist, see the production guide. For serverless deployment, check the serverless PDF generation guide.

Recommended tsconfig.json

Enable strict type checking for PDF API projects:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "esModuleInterop": true,
    "skipLibCheck": true
  }
}

noUncheckedIndexedAccess adds | undefined to array and object index access, which is especially useful when processing batch generation results.

Summary

Key benefits of combining TypeScript with PDF generation APIs:

  • Type definitions prevent typos in option names and template variables — IDE autocompletion reduces mistakes dramatically
  • Generics enforce template data contracts — each template has a compile-time data requirement
  • Zod unifies runtime validation and typesz.infer eliminates duplicate definitions
  • Discriminated unions force error handling — the compiler ensures both success and failure paths are covered
  • Ready for Next.js, Express, and Deno — patterns work across all TypeScript runtimes

Try your HTML templates in the playground before defining types around them. For use case inspiration, browse the use cases page. If you don't have an account yet, you can get started for free.

Powered by FUNBREW PDF