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 types —
z.infereliminates 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.