NaN/NaN/NaN

PDF生成APIをTypeScriptプロジェクトに組み込むとき、「APIのレスポンス型が不明」「テンプレートの変数名を間違えてもコンパイル時に気づけない」といった問題に直面します。JavaScriptで書くとランタイムまでエラーが見つからないこれらの問題は、TypeScriptの型システムを活用することで開発時に検出できます。

この記事ではFUNBREW PDFのAPIを題材に、TypeScriptで型安全にPDF生成を行うための設計パターンと実装例を紹介します。SDKの基本的な使い方は言語別クイックスタートを、HTMLからPDFへの変換の基礎はHTML→PDF変換 完全ガイドを参照してください。

なぜPDF APIにTypeScriptが必要か

PDF生成では、HTMLテンプレート・オプション・レスポンスなど多くのデータ構造を扱います。型がないと次のようなバグが潜みやすくなります。

問題 JavaScriptの場合 TypeScriptなら
オプション名のタイポ ランタイムで無視される コンパイルエラーで検出
テンプレート変数の不足 不完全なPDFが生成される 型チェックで事前検出
レスポンスの型不明 anyで扱い実行時エラー 型推論でIDEが補完
エンジン指定ミス APIエラー(400) ユニオン型で制約

型の恩恵はコードの書き心地だけでなく、チーム開発でのドキュメント効果にもなります。

SDK(@funbrew/pdf)の型定義

FUNBREW PDF Node.js SDKはTypeScriptで書かれており、型定義が同梱されています。追加の@typesパッケージは不要です。

npm install @funbrew/pdf

SDKが提供する主要な型を確認しましょう。

import { FunbrewPdf } from '@funbrew/pdf'

// クライアントの初期化 — apiKey は string 型が必須
const client = new FunbrewPdf({
  apiKey: process.env.FUNBREW_PDF_API_KEY!,
})

// generate メソッドの引数型
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
  }
}

engineフィールドはユニオン型'quality' | 'fast'なので、存在しないエンジン名を指定するとコンパイルエラーになります。qualityはChromiumベース(高品質)、fastはwkhtmltopdfベース(高速)です。エンジンの違いはwkhtmltopdf vs Chromium 比較記事で詳しく解説しています。

APIレスポンスの型安全な扱い

SDKを使わずfetchで直接APIを呼ぶ場合でも、型を定義しておくとミスを防げます。

// types/funbrew.ts

interface PdfGenerateRequest {
  html: string
  options?: {
    format?: 'A3' | 'A4' | 'A5' | 'Letter' | 'Legal'
    landscape?: boolean
    margin?: { top?: string; bottom?: string; left?: string; right?: string }
    engine?: 'quality' | 'fast'
  }
}

interface PdfErrorResponse {
  error: string
  message?: string
  statusCode: number
}

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生成エラー: ${error.message ?? error.error}`)
  }

  return response.arrayBuffer()
}

エラーハンドリングのベストプラクティスはエラーハンドリングガイドで詳しく解説しています。

ジェネリクスでテンプレートを型安全に

PDF生成で最もバグが起きやすいのはテンプレート変数の差し込み部分です。ジェネリクスを使ってテンプレートビルダーを型安全にしましょう。

// lib/pdf-template.ts

/** テンプレートビルダーの基底型 */
interface PdfTemplate<TData> {
  name: string
  build: (data: TData) => string
}

/** 請求書テンプレートのデータ型 */
interface InvoiceData {
  invoiceNumber: string
  companyName: string
  customerName: string
  issueDate: string
  dueDate: string
  items: InvoiceItem[]
  taxRate: number
}

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

/** 請求書テンプレート */
const invoiceTemplate: PdfTemplate<InvoiceData> = {
  name: 'invoice',
  build: (data) => {
    const subtotal = data.items.reduce(
      (sum, item) => sum + item.quantity * item.unitPrice, 0
    )
    const tax = Math.floor(subtotal * data.taxRate)
    const total = subtotal + tax

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

    // HTMLテンプレートを返す(CSS・テーブル・合計表示を含む)
    return `<!DOCTYPE html>
<html lang="ja">
<head><meta charset="UTF-8">
  <style>
    body { font-family: 'Noto Sans JP', sans-serif; padding: 40px; }
    h1 { color: #1a56db; }
    table { width: 100%; border-collapse: collapse; margin: 20px 0; }
    th, td { border: 1px solid #e5e7eb; padding: 10px; }
    .right { text-align: right; }
    .total { font-size: 1.4em; font-weight: bold; color: #1a56db; text-align: right; }
  </style>
</head>
<body>
  <h1>請求書 #${data.invoiceNumber}</h1>
  <p>請求先: ${data.customerName} 様</p>
  <table><thead><tr><th>品目</th><th>数量</th><th>単価</th><th>小計</th></tr></thead>
    <tbody>${rows}</tbody></table>
  <p class="total">合計: &yen;${total.toLocaleString()}</p>
</body></html>`
  },
}

このパターンを使えば、テンプレートごとにデータ型が定義されるため、必要なフィールドの漏れをコンパイル時に検出できます。テンプレートエンジンとの組み合わせはテンプレートエンジン活用ガイドも参考にしてください。

テンプレートレジストリ

複数のテンプレートをマップで管理する場合、条件付き型推論でデータ型を自動解決できます。

// lib/pdf-registry.ts

type TemplateRegistry = {
  invoice: PdfTemplate<InvoiceData>
  certificate: PdfTemplate<{ recipientName: string; courseName: string; completionDate: string }>
  report: PdfTemplate<{ title: string; author: string; sections: Array<{ heading: string; content: string }> }>
}

/** テンプレート名からデータ型を自動推論する生成関数 */
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 })
}

// 使用例 — 'invoice' を渡すと data は InvoiceData 型に推論される
const pdf = await generateFromTemplate(client, 'invoice', {
  invoiceNumber: 'INV-2026-001',
  companyName: '株式会社FUNBREW',
  customerName: '山田太郎',
  issueDate: '2026-04-04',
  dueDate: '2026-04-30',
  items: [{ name: 'PDF API プラン', quantity: 1, unitPrice: 5000 }],
  taxRate: 0.1,
})

請求書PDFの自動化パターンは請求書PDF自動化ガイドで、証明書は証明書PDF自動化ガイドで詳しく解説しています。

Zodバリデーションとの統合

外部からのリクエスト(APIエンドポイントやWebhook)を受け取る場合、TypeScriptの型だけではランタイムの安全性は保証できません。Zodを組み合わせることで、型定義とランタイムバリデーションを一元管理できます。

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

/** PDF生成オプションのスキーマ */
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生成リクエストのスキーマ */
const pdfGenerateSchema = z.object({
  html: z.string().min(1, 'HTMLは必須です').max(5_000_000, 'HTMLが大きすぎます'),
  options: pdfOptionsSchema.optional(),
  filename: z.string().regex(/^[\w\-\.]+\.pdf$/).optional(),
})

/** スキーマから型を自動導出 */
type PdfGenerateInput = z.infer<typeof pdfGenerateSchema>
type PdfOptions = z.infer<typeof pdfOptionsSchema>

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

このz.inferパターンにより、スキーマの定義と型定義が常に同期します。Zodスキーマを変更すれば型も自動的に更新されるため、二重管理によるずれが発生しません。

先述の請求書データ型も同様にZodスキーマ化すれば、外部入力のバリデーションと型定義を一箇所で管理できます。

Next.js App Routerでの実践

上記のZodスキーマをNext.jsのAPI Routeに組み込みます。Next.js/Nuxt連携の基礎はNext.js・Nuxt連携ガイドで解説しています。

// 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) {
  const result = pdfGenerateSchema.safeParse(await request.json())

  if (!result.success) {
    return NextResponse.json(
      { error: 'バリデーションエラー', details: result.error.flatten().fieldErrors },
      { status: 400 }
    )
  }

  // result.data は 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) {
    return NextResponse.json({ error: 'PDF生成に失敗しました' }, { status: 500 })
  }
}

safeParseの結果result.dataは自動的にPdfGenerateInput型になるため、以降のコードは完全に型安全です。

Expressでの実践

Express + TypeScriptではZodバリデーションミドルウェアを作ると再利用しやすくなります。

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

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.flatten().fieldErrors })
    }
    req.body = result.data
    next()
  }
}
// routes/pdf.ts
import { Router } 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, res) => {
  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) {
    res.status(500).json({ error: 'Failed to generate PDF' })
  }
})

Denoでの実践

Deno(2.x)はTypeScriptをネイティブサポートしています。npmパッケージもnpm:プレフィックスで利用可能です。

// 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') return new Response('Not Found', { status: 404 })

  const result = requestSchema.safeParse(await req.json())
  if (!result.success) {
    return Response.json({ error: 'Validation failed', details: result.error.flatten() }, { status: 400 })
  }

  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"' },
  })
})
deno run --allow-net --allow-env server.ts

Discriminated Unionでエラーを型安全に

PDF生成の結果をSuccess/Failureパターンで表現すると、呼び出し側がエラーケースの処理を強制されます。

// lib/pdf-result.ts

type PdfResult =
  | { success: true; buffer: ArrayBuffer; metadata: { sizeBytes: number; engine: 'quality' | 'fast' } }
  | { success: false; error: { code: 'API_ERROR' | 'TIMEOUT' | 'RATE_LIMITED'; message: string; retryable: boolean } }

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, engine: request.options?.engine ?? 'quality' },
    }
  } catch (error) {
    if (error instanceof Error && error.message.includes('429')) {
      return { success: false, error: { code: 'RATE_LIMITED', message: 'レート制限超過', retryable: true } }
    }
    return { success: false, error: { code: 'API_ERROR', message: String(error), retryable: false } }
  }
}

// 使用例 — TypeScriptが success の分岐を強制する
const result = await generatePdfSafe(client, { html: '<h1>Hello</h1>' })

if (result.success) {
  console.log(`PDF生成完了: ${result.metadata.sizeBytes} bytes`)
} else {
  if (result.error.retryable) {
    console.log('リトライ可能なエラー:', result.error.message)
  }
}

一括生成の型安全な実装

複数のPDFを一括生成する場合も、ジェネリクスで型安全に管理できます。

// lib/batch-pdf.ts

interface BatchItem<T> {
  id: 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()) {
      results.push(
        result.status === 'fulfilled'
          ? { id: result.value.id, status: 'fulfilled', sizeBytes: result.value.buffer.byteLength }
          : { id: chunk[index].id, status: 'rejected', error: String(result.reason) }
      )
    }
  }

  return results
}

大量ドキュメントの一括生成については一括生成ガイドで詳しく解説しています。

Markdown→PDF をTypeScriptで

FUNBREW PDFはMarkdownからの直接PDF生成(POST /api/pdf/generate-from-markdown)もサポートしています。as constとZodを組み合わせてテーマ名を型安全に管理できます。

// 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コンテンツは必須です'),
  theme: z.enum(markdownThemes).default('default'),
  options: z.object({
    format: z.enum(['A3', 'A4', 'A5', 'Letter', 'Legal']).default('A4'),
  }).optional(),
})

type MarkdownPdfInput = z.infer<typeof markdownPdfSchema>
type MarkdownTheme = typeof markdownThemes[number] // 'default' | 'github' | ...

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生成エラー: ${response.status}`)
  return response.arrayBuffer()
}

Markdown→PDF変換の詳細はMarkdown→PDF APIガイドを参照してください。

本番運用でのTypeScript活用パターン

環境変数の型安全な管理

Zodで起動時に環境変数をバリデーションすれば、デプロイ後の「APIキー未設定」事故を防げます。

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

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

export const env = envSchema.parse(process.env) // 起動時にバリデーション

セキュリティ上の注意点はセキュリティガイドで確認してください。

型安全なリトライ関数

ジェネリクスを使えば、リトライ関数も汎用的に書けます。

async function withRetry<T>(
  fn: () => Promise<T>,
  maxRetries = 3,
  baseDelayMs = 1000
): Promise<T> {
  let lastError: Error | undefined
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await fn()
    } catch (error) {
      lastError = error as Error
      if (attempt < maxRetries) {
        await new Promise(r => setTimeout(r, baseDelayMs * Math.pow(2, attempt - 1)))
      }
    }
  }
  throw lastError
}

// 使用例 — 戻り値の型は自動推論される
const pdfBuffer = await withRetry(() =>
  client.generate({ html: '<h1>Hello</h1>', options: { format: 'A4' } })
)

本番運用の包括的なチェックリストは本番運用ガイドをご覧ください。サーバーレス環境での実行はサーバーレスPDF生成ガイドも参考にしてください。

tsconfig.json の推奨設定

PDF APIプロジェクトではstrict: trueに加えてnoUncheckedIndexedAccess: trueを推奨します。配列のインデックスアクセスに| undefinedが付与され、一括生成結果の配列を安全に扱えます。

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler"
  }
}

まとめ

TypeScriptをPDF生成APIと組み合わせることで得られるメリットをまとめます。

  • 型定義でオプション名やテンプレート変数のタイポを防ぐ — IDEの補完でミスを大幅に削減
  • ジェネリクスでテンプレートビルダーを型安全に — テンプレートごとにデータ型を強制
  • Zodでランタイムバリデーションと型を一元管理z.inferで二重管理を排除
  • Discriminated Unionでエラー処理を強制 — 成功/失敗の分岐をコンパイラがチェック
  • Next.js / Express / Denoで即実践可能 — 各ランタイムのパターンをそのまま利用可能

プレイグラウンドでHTMLテンプレートを試してから型定義に落とし込むと、開発が効率的に進みます。ユースケース別の活用例はユースケース一覧もご参照ください。まだアカウントをお持ちでない方は、無料で始められます

Powered by FUNBREW PDF