---
title: "Data Extraction"
description: "Extract structured data from unstructured text, validated against a Zod schema."
canonical: "https://nuabase.com/docs/examples/data-extraction/"
---

> Documentation index: https://nuabase.com/llms.txt

This example extracts structured data from unstructured text, using the schema to validate what comes back.

## Problem

You receive data in various unstructured formats (emails, forms, documents) and need to:

1. Extract specific fields reliably
2. Normalize data to a consistent format
3. Validate extracted values
4. Handle missing or ambiguous data gracefully

## Solution: Address Extraction

```typescript
import { Nua, z } from 'nuabase';
import { findLocalAgent } from 'nuabase/local-agent';

const nua = Nua.direct({ localAgent: await findLocalAgent() });

// Define a strict address schema
const AddressSchema = z.object({
  street: z.string(),
  apartment: z.string().optional(),
  city: z.string(),
  state: z.string().length(2),
  zipCode: z.string().regex(/^\d{5}(-\d{4})?$/),
});

// Unstructured address inputs
const rawAddresses = [
  "Please ship to 123 Main Street, Apt 4B, Springfield, Illinois 62701",
  "My address is 456 Oak Ave Chicago IL",
  "Send it to: 789 Pine Road, Suite 100, New York, NY 10001-1234",
  "deliver to 321 Elm St., Los Angeles California 90210",
];

async function extractAddresses() {
  for (const raw of rawAddresses) {
    const result = await nua.get(
      `Extract the mailing address from the text. Normalize the state to a 2-letter code. If zip code is missing, use "00000" as placeholder.`,
      {
        input: raw,
        output: { name: 'address', schema: AddressSchema }
      }
    );

    console.log(`Input: "${raw}"`);

    if (result.success) {
      const addr = result.data;
      console.log(`  → ${addr.street}${addr.apartment ? `, ${addr.apartment}` : ''}`);
      console.log(`    ${addr.city}, ${addr.state} ${addr.zipCode}`);
    } else {
      console.log(`  → Error: ${result.error}`);
    }
    console.log();
  }
}

extractAddresses();
```

## Output

From a run on Claude Code (`claude-code:claude-opus-5-5`), with the default logger's `[INFO]` lines left out:

```text
Input: "Please ship to 123 Main Street, Apt 4B, Springfield, Illinois 62701"
  → 123 Main Street, Apt 4B
    Springfield, IL 62701

Input: "My address is 456 Oak Ave Chicago IL"
  → 456 Oak Ave
    Chicago, IL 00000

Input: "Send it to: 789 Pine Road, Suite 100, New York, NY 10001-1234"
  → 789 Pine Road, Suite 100
    New York, NY 10001-1234

Input: "deliver to 321 Elm St., Los Angeles California 90210"
  → 321 Elm St.
    Los Angeles, CA 90210
```

## Solution: Contact Information Extraction

```typescript
const ContactSchema = z.object({
  name: z.object({
    first: z.string(),
    last: z.string(),
    title: z.string().optional(),
  }),
  email: z.string().optional(),
  phone: z.string().optional(),
  company: z.string().optional(),
  role: z.string().optional(),
});

const emailSignatures = [
  `Best regards,
   Dr. Jane Smith
   Chief Technology Officer
   Acme Corporation
   jane.smith@acme.com
   (555) 123-4567`,

  `Thanks,
   Mike Johnson | Senior Developer
   mike@startup.io`,

  `Cheers,
   Lisa Park
   lisa.park@agency.co | 555.987.6543
   Creative Director at BigAgency`,
];

async function extractContacts() {
  const contacts = [];

  for (const signature of emailSignatures) {
    const result = await nua.get(
      "Extract contact information from this email signature",
      {
        input: signature,
        output: { name: 'contact', schema: ContactSchema }
      }
    );

    if (result.success) {
      contacts.push(result.data);
    }
  }

  console.log('Extracted Contacts:');
  console.log(JSON.stringify(contacts, null, 2));
}

extractContacts();
```

Output, from the same run:

```text
Extracted Contacts:
[
  {
    "name": {
      "first": "Jane",
      "last": "Smith",
      "title": "Dr."
    },
    "email": "jane.smith@acme.com",
    "phone": "(555) 123-4567",
    "company": "Acme Corporation",
    "role": "Chief Technology Officer"
  },
  {
    "name": {
      "first": "Mike",
      "last": "Johnson"
    },
    "email": "mike@startup.io",
    "role": "Senior Developer"
  },
  {
    "name": {
      "first": "Lisa",
      "last": "Park"
    },
    "email": "lisa.park@agency.co",
    "phone": "555.987.6543",
    "company": "BigAgency",
    "role": "Creative Director"
  }
]
```

Optional fields with nothing to fill them, such as Mike's phone and company, are left out rather than filled with empty strings.

## Solution: Invoice Data Extraction

```typescript
const LineItemSchema = z.object({
  description: z.string(),
  quantity: z.number().positive(),
  unitPrice: z.number().nonnegative(),
  total: z.number().nonnegative(),
});

const InvoiceSchema = z.object({
  invoiceNumber: z.string(),
  date: z.string(),
  vendor: z.object({
    name: z.string(),
    address: z.string().optional(),
  }),
  lineItems: z.array(LineItemSchema),
  subtotal: z.number(),
  tax: z.number().optional(),
  total: z.number(),
});

const invoiceText = `
INVOICE #INV-2024-0042
Date: January 15, 2024

From: TechSupplies Inc.
      123 Business Park, Suite 400
      San Francisco, CA 94102

Items:
- Wireless Mouse (x10) @ $25.00 each = $250.00
- USB-C Hub (x5) @ $45.00 each = $225.00
- Monitor Stand (x3) @ $89.99 each = $269.97

Subtotal: $744.97
Tax (8.5%): $63.32
Total Due: $808.29
`;

async function extractInvoice() {
  const result = await nua.get(
    "Extract all invoice data from this document",
    {
      input: invoiceText,
      output: { name: 'invoice', schema: InvoiceSchema }
    }
  );

  if (result.success) {
    const inv = result.data;
    console.log(`Invoice: ${inv.invoiceNumber}`);
    console.log(`From: ${inv.vendor.name}`);
    console.log(`Date: ${inv.date}`);
    console.log(`\nLine Items:`);
    for (const item of inv.lineItems) {
      console.log(`  ${item.description}: ${item.quantity} x $${item.unitPrice} = $${item.total}`);
    }
    console.log(`\nSubtotal: $${inv.subtotal}`);
    console.log(`Tax: $${inv.tax}`);
    console.log(`Total: $${inv.total}`);
  }
}

extractInvoice();
```

Output:

```text
Invoice: INV-2024-0042
From: TechSupplies Inc.
Date: January 15, 2024

Line Items:
  Wireless Mouse: 10 x $25 = $250
  USB-C Hub: 5 x $45 = $225
  Monitor Stand: 3 x $89.99 = $269.97

Subtotal: $744.97
Tax: $63.32
Total: $808.29
```

`date` is a free-form `z.string()`, so it comes back as written in the invoice. Use `z.iso.date()` and say so in the prompt if you need `2024-01-15`.

## Batch Extraction with List

When extracting from multiple documents:

```typescript
const documents = [
  { id: 'doc-1', content: 'Invoice #001...' },
  { id: 'doc-2', content: 'Invoice #002...' },
  { id: 'doc-3', content: 'Invoice #003...' },
];

const result = await nua.list("Extract invoice data from each document", {
  input: documents,
  primaryKey: 'id',
  output: { name: 'invoice', schema: InvoiceSchema }
});

if (result.success) {
  for (const row of result.data) {
    console.log(`${row.id}: Invoice ${row.invoice.invoiceNumber} - $${row.invoice.total}`);
  }
}
```

## Handling Missing Data

Make fields optional when the source may not contain them, and check what came back:

```typescript
const FlexibleContactSchema = z.object({
  name: z.string(),
  email: z.string().optional(),
  phone: z.string().optional(),
});

const result = await nua.get('Extract contact info', {
  input: signature,
  output: { name: 'contact', schema: FlexibleContactSchema },
});

if (result.success && !result.data.email && !result.data.phone) {
  console.log('Warning: no contact method found');
}
```

To require a cross-field rule such as "either email or phone", add a `.refine()`. An answer that breaks it is retried, and if every attempt breaks it, the call fails and `reason` says why:

```typescript
const ContactWithMethod = FlexibleContactSchema.refine(
  (c) => c.email || c.phone,
  { message: 'Either email or phone is required' },
);

const result = await nua.get('Extract contact info. Include an email or a phone number.', {
  input: signature,
  output: { name: 'contact', schema: ContactWithMethod },
});

if (!result.success) {
  console.log(`Flag for review: ${result.reason}`);
}
```

Use a `.refine()` when an answer without the rule is wrong. When the source text may simply lack the data, leave the fields optional and check what came back, as above.

## Best Practices

1. **Use enums and numbers** where the value is constrained, rather than free-form strings
2. **Make fields optional** when data might not be present
3. **Add formats and regex patterns** for formatted data: `z.email()` for email addresses, `.regex()` for phone numbers and zip codes
4. **Express cross-field rules with `.refine()`**, and mention them in the prompt so the model knows them
5. **Normalize during extraction**: ask the LLM to convert formats, such as state names to 2-letter codes
