npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

expo-contact-picker

v0.0.3

Published

Native iOS contact picker for Expo — SwiftUI-powered with a clean JS API

Readme

expo-contact-picker

A high-quality, SwiftUI-powered native iOS contact picker for Expo. Opens the familiar iOS Contacts UI, lets users pick one or many contacts, and returns structured data to JavaScript with full TypeScript support.

Platform: iOS only (v1). Android support is planned for a future release.


Table of Contents


Features

  • Native iOS UI — uses CNContactPickerViewController, the same picker Apple ships in the Contacts app
  • Single & multi-select — controlled via a single multiple option
  • Field filtering — grey out contacts that lack phone numbers or email addresses
  • Typed API — TypeScript overloads ensure the return type matches the multiple flag at compile time
  • No explicit permission call neededCNContactPickerViewController handles Contacts authorization internally
  • Graceful non-iOS stub — importing the module on Android or web never crashes; only calling openContactPicker throws an informative error

Requirements

| Tool | Minimum version | |---|---| | Expo SDK | 50+ | | React Native | 0.73+ | | iOS | 15.0+ | | Xcode | 15+ | | Node | 18+ |


Installation

# npm
npm install expo-contact-picker

# yarn
yarn add expo-contact-picker

# pnpm
pnpm add expo-contact-picker

Then rebuild the native app:

npx expo prebuild --clean
npx expo run:ios

Expo Go — native modules are not supported in Expo Go. Use a development build instead.


Setup

1. Add the privacy description (required by Apple)

Edit your app.json / app.config.js and add a NSContactsUsageDescription:

// app.json
{
  "expo": {
    "ios": {
      "infoPlist": {
        "NSContactsUsageDescription": "We need access to your contacts so you can select who to send money to."
      }
    }
  }
}

Apple will reject your app during review if this key is missing.

2. Prebuild / link

npx expo prebuild

The podspec (ExpoContactPicker.podspec) is picked up automatically by CocoaPods during prebuild. No manual Podfile edits are required.

3. Run on device or simulator

npx expo run:ios

Quick Start

import { openContactPicker } from 'expo-contact-picker';
import { Button, Text, View } from 'react-native';

export default function App() {
  const handlePick = async () => {
    const contact = await openContactPicker();
    if (contact) {
      console.log(contact.name);          // "Jane Appleseed"
      console.log(contact.phoneNumbers);  // ["+1 (555) 123-4567"]
    }
  };

  return (
    <View>
      <Button title="Pick a contact" onPress={handlePick} />
    </View>
  );
}

API Reference

openContactPicker(options?)

Opens the native iOS contact picker. Returns a Promise that resolves when the user selects a contact or cancels.

// Single-select (default)
function openContactPicker(options?: PickerOptions & { multiple?: false }): Promise<Contact | null>

// Multi-select
function openContactPicker(options: PickerOptions & { multiple: true }): Promise<Contact[]>

Return values

| Scenario | Return value | |---|---| | User picks a contact (single) | Contact object | | User cancels (single) | null | | User picks contacts (multi) | Contact[] (length ≥ 1) | | User cancels (multi) | Contact[] (empty array []) |

Throws

Throws a ContactPickerError for hard errors (permission denied, no view controller). Cancellation is not an error — it returns null / [].


ContactPickerError

Extends the built-in Error class with an extra code property.

class ContactPickerError extends Error {
  code: NativeErrorCode;
}

Error codes

| code | When it occurs | |---|---| | PERMISSION_DENIED | The user previously denied Contacts access in Settings | | PERMISSION_RESTRICTED | Contacts access is restricted by a device policy (MDM, parental controls) | | NO_VIEW_CONTROLLER | Could not find a UIViewController to present the picker from (rare) | | UNKNOWN_ERROR | Unexpected native error |

import { openContactPicker, ContactPickerError } from 'expo-contact-picker';

try {
  const contact = await openContactPicker();
} catch (e) {
  if (e instanceof ContactPickerError) {
    switch (e.code) {
      case 'PERMISSION_DENIED':
        // Prompt the user to open Settings → Privacy → Contacts
        break;
      case 'PERMISSION_RESTRICTED':
        // Inform the user their device policy blocks Contacts
        break;
      default:
        console.error(e.message);
    }
  }
}

Types

Contact

type Contact = {
  /** Stable iOS contact identifier (CNContact.identifier). */
  id: string;

  /** Full formatted display name, e.g. "Jane Appleseed". */
  name: string;

  /** Given / first name. */
  firstName?: string;

  /** Family / last name. */
  lastName?: string;

  /** All phone number strings, e.g. ["+1 (555) 123-4567", "+44 7911 123456"]. */
  phoneNumbers: string[];

  /** All email address strings, e.g. ["[email protected]"]. */
  emails: string[];
};

PickerOptions

type PickerOptions = {
  /**
   * Allow the user to select more than one contact.
   * When true the picker shows checkboxes and a Done button.
   * Defaults to false.
   */
  multiple?: boolean;

  /**
   * Restrict selectable contacts by the fields they contain.
   * Contacts lacking the requested fields are shown but greyed out.
   *
   * "phone"  — contact must have at least one phone number
   * "email"  — contact must have at least one email address
   *
   * Passing both ["phone", "email"] enables contacts that have
   * either a phone number OR an email address.
   *
   * Omit or pass [] to enable all contacts.
   */
  fields?: ('phone' | 'email')[];
};

ContactField

type ContactField = 'phone' | 'email';

Examples

Single contact selection

import { openContactPicker } from 'expo-contact-picker';

const contact = await openContactPicker();

if (contact === null) {
  // User tapped Cancel
  return;
}

console.log(contact.id);           // "E24B4E64-6B28-..."
console.log(contact.name);        // "Jane Appleseed"
console.log(contact.firstName);   // "Jane"
console.log(contact.lastName);    // "Appleseed"
console.log(contact.phoneNumbers); // ["+1 (555) 123-4567"]
console.log(contact.emails);       // ["[email protected]"]

Multi-contact selection

import { openContactPicker } from 'expo-contact-picker';

const contacts = await openContactPicker({ multiple: true });

if (contacts.length === 0) {
  // User tapped Cancel without selecting anyone
  return;
}

contacts.forEach(c => {
  console.log(`${c.name}: ${c.phoneNumbers.join(', ')}`);
});

Filter by field

// Only contacts with a phone number are selectable
const contact = await openContactPicker({ fields: ['phone'] });

// Only contacts with an email are selectable
const contact = await openContactPicker({ fields: ['email'] });

// Contacts with either a phone OR email are selectable
const contact = await openContactPicker({ fields: ['phone', 'email'] });

Full error handling

import { openContactPicker, ContactPickerError } from 'expo-contact-picker';
import { Alert, Linking } from 'react-native';

async function pickContact() {
  try {
    const contact = await openContactPicker({ fields: ['phone'] });

    if (!contact) return; // cancelled

    sendMoney(contact.phoneNumbers[0]);
  } catch (e) {
    if (e instanceof ContactPickerError) {
      if (e.code === 'PERMISSION_DENIED') {
        Alert.alert(
          'Contacts Access Required',
          'Please enable Contacts access in Settings to use this feature.',
          [
            { text: 'Cancel', style: 'cancel' },
            { text: 'Open Settings', onPress: () => Linking.openSettings() },
          ],
        );
      } else {
        Alert.alert('Error', e.message);
      }
    }
  }
}

React hook wrapper

A convenient custom hook you can drop into your project:

// hooks/useContactPicker.ts
import { useState, useCallback } from 'react';
import {
  openContactPicker,
  ContactPickerError,
  type Contact,
  type PickerOptions,
} from 'expo-contact-picker';

type State = {
  contact: Contact | null;
  loading: boolean;
  error: ContactPickerError | null;
};

export function useContactPicker(options?: PickerOptions & { multiple?: false }) {
  const [state, setState] = useState<State>({
    contact: null,
    loading: false,
    error: null,
  });

  const pick = useCallback(async () => {
    setState(s => ({ ...s, loading: true, error: null }));
    try {
      const contact = await openContactPicker(options);
      setState({ contact, loading: false, error: null });
      return contact;
    } catch (e) {
      const error = e instanceof ContactPickerError ? e : new ContactPickerError('UNKNOWN_ERROR');
      setState(s => ({ ...s, loading: false, error }));
      return null;
    }
  }, [options]);

  return { ...state, pick };
}

Usage:

import { useContactPicker } from './hooks/useContactPicker';
import { Button, Text, View } from 'react-native';

export default function SendMoney() {
  const { contact, loading, error, pick } = useContactPicker({ fields: ['phone'] });

  return (
    <View>
      <Button title="Select Recipient" onPress={pick} disabled={loading} />
      {contact && <Text>Sending to: {contact.name}</Text>}
      {error?.code === 'PERMISSION_DENIED' && (
        <Text>Please allow Contacts access in Settings.</Text>
      )}
    </View>
  );
}

Permissions

CNContactPickerViewController manages the Contacts permission prompt automatically on first use — you do not need to call Contacts.requestPermissionsAsync() yourself.

However you must add NSContactsUsageDescription to your Info.plist (see Setup), otherwise:

  • iOS 17+: the app will crash at launch
  • App Store review: automatic rejection

Permission flow

openContactPicker()
       │
       ▼
Not determined?  ──yes──▶  iOS shows system permission alert
       │                          │
       │no                   Granted?
       ▼                    yes ──▶ picker opens
Denied/Restricted?           no ──▶ ContactPickerError('PERMISSION_DENIED')
       │
       ▼
ContactPickerError('PERMISSION_DENIED' | 'PERMISSION_RESTRICTED')

Architecture

┌─────────────────────────────────────────────────────────┐
│  JavaScript / TypeScript                                │
│                                                         │
│  openContactPicker(options)  ──▶  ContactPickerError    │
│         │                                               │
│  src/index.ts (overloads, error shaping)                │
│         │                                               │
│  src/ExpoContactPickerModule.ts (requireNativeModule)   │
└──────────────────────┬──────────────────────────────────┘
                       │  Expo Modules Core bridge
┌──────────────────────▼──────────────────────────────────┐
│  Swift — ios/                                           │
│                                                         │
│  ExpoContactPickerModule.swift                          │
│  ├─ Module definition (Name / AsyncFunction)            │
│  ├─ PickerOptions Record (@Field multiple, fields)      │
│  ├─ topViewController() helper                          │
│  ├─ enablePredicate(fields) — CNContact NSPredicate     │
│  └─ contactToDict(_ contact: CNContact)                 │
│                                                         │
│  ContactPickerDelegate.swift                            │
│  ├─ SingleSelectDelegate : CNContactPickerDelegate      │
│  │    didSelect contact:  → resolve([contact])          │
│  │    contactPickerDidCancel → resolve(USER_CANCELLED)  │
│  └─ MultiSelectDelegate  : CNContactPickerDelegate      │
│       didSelect contacts: → resolve([...contacts])      │
│       contactPickerDidCancel → resolve(USER_CANCELLED)  │
└─────────────────────────────────────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────────────┐
│  iOS Contacts Framework                                 │
│  CNContactPickerViewController                          │
│  CNContact  ·  CNContactFormatter  ·  CNLabeledValue    │
└─────────────────────────────────────────────────────────┘

Why two delegate classes?

CNContactPickerViewController detects the selection mode by inspecting which delegate methods are implemented:

  • Only didSelect contact: (singular) → single-select mode (no checkboxes)
  • Only didSelect contacts: (plural) → multi-select mode (checkboxes + Done button)

By keeping SingleSelectDelegate and MultiSelectDelegate as separate classes that each implement only one variant, the native picker automatically shows the correct UI with zero extra configuration.

The delegate retention pattern

The active delegate is stored as a strong reference in ExpoContactPickerModule.activeDelegate. This ensures the delegate is not deallocated while the picker is on screen. The onDismiss closure passed to each delegate sets activeDelegate = nil when the picker finishes, releasing the reference.


Troubleshooting

"Native module ExpoContactPicker not found"

  • Make sure you ran npx expo prebuild and npx expo run:ios after installing
  • Expo Go is not supported — use a development build
  • Check that the podspec file is present and pod install ran successfully

The picker opens but contacts are all greyed out

When you pass fields: ['phone'], contacts without phone numbers are disabled. If all your test contacts happen to have no numbers stored in the simulator, add a phone number to one of them in the Contacts app.

The app crashes with NSContactsUsageDescription missing

Add the key to your app.json under expo.ios.infoPlist and rebuild (see Setup).

"openContactPicker is only supported on iOS"

You are calling openContactPicker on Android or web. Guard the call with a platform check:

import { Platform } from 'react-native';

if (Platform.OS === 'ios') {
  const contact = await openContactPicker();
}

TypeScript: "Argument of type '{ multiple: true }' is not assignable…"

Make sure you are on TypeScript 4.9+ and have imported from 'expo-contact-picker' (not a deep path). Restart the TS server (Cmd+Shift+P → TypeScript: Restart TS Server in VS Code).


Contributing

Pull requests are welcome. For major changes, open an issue first to discuss the approach.

git clone https://github.com/iraosandeep/expo-contact-picker.git
cd expo-contact-picker
npm install
npm run build

To test changes in a real app, use npm pack to create a tarball and install it:

npm pack
# in your test app:
npm install ../expo-contact-picker/iraosandeep-expo-contact-picker-1.0.0.tgz
npx expo run:ios

License

MIT © iraosandeep