A fully featured, RFC 6350-compliant vCard v4 library for Node.js and TypeScript.
Designed for use with CardDAV servers and clients. The parser is deliberately tolerant — it handles vCard v2.1, v3.0, and v4.0 input, Apple Contacts exports, QUOTED-PRINTABLE encoding, and various real-world quirks without throwing. The generator is deliberately strict — it produces RFC 6350-compliant output with CRLF line endings, UTF-8 byte-accurate line folding, and full validation.
npm install @pipobscure/vcardRequires Node.js 18 or later (uses Buffer, ES modules).
import { VCard, FNProperty, EmailProperty, TelProperty, NProperty } from '@pipobscure/vcard';
// --- Parse ---
const vcards = VCard.parse(rawText);
const vc = vcards[0];
console.log(vc.displayName); // 'Alice Example'
console.log(vc.primaryEmail); // 'alice@example.com'
// --- Build ---
const vc = new VCard();
vc.fn.push(new FNProperty('Alice Example'));
vc.n = new NProperty({
familyNames: ['Example'],
givenNames: ['Alice'],
additionalNames: [],
honorificPrefixes: [],
honorificSuffixes: [],
});
vc.email.push(new EmailProperty('alice@example.com'));
const text = vc.toString();
// BEGIN:VCARD\r\n
// VERSION:4.0\r\n
// FN:Alice Example\r\n
// ...
// END:VCARD\r\n- Parsing
- Generating
- The VCard class
- Property classes
- Date and time values
- Escaping utilities
- Validation
- RFC compliance notes
Parse one or more vCards from a string. Returns an array (empty if no vCards are found). Never throws — malformed input is handled tolerantly and a list of parseWarnings is attached to each resulting VCard.
import { VCard } from '@pipobscure/vcard';
const vcards = VCard.parse(text);
for (const vc of vcards) {
if (vc.parseWarnings.length > 0) {
console.warn('Parse warnings:', vc.parseWarnings);
}
console.log(vc.displayName);
}Parse exactly one vCard. Throws Error if the input contains no vCards.
const vc = VCard.parseOne(text);import { parse, parseOne } from '@pipobscure/vcard';
const vcards = parse(text); // same as VCard.parse()
const vc = parseOne(text); // same as VCard.parseOne()The parser handles all of the following without throwing:
- vCard versions 2.1, 3.0, and 4.0
- LF-only line endings (in addition to RFC-required CRLF)
- Mixed line endings within a single file
- Folded content lines (CRLF + whitespace continuation)
ENCODING=QUOTED-PRINTABLEwith multi-byte UTF-8 sequences (v2.1/v3.0)ENCODING=b(base64) parameter flag (v3.0 syntax)- Case-insensitive property names and parameter names
- Comma-separated
TYPEvalues (TYPE=WORK,VOICE) - Quoted parameter values with commas (
TYPE="work,voice") - Item-grouped properties (
item1.EMAIL,item1.X-ABLabel) - Unknown / proprietary properties (stored verbatim as
UnknownProperty) - Multiple vCards in a single string
- Content before
BEGIN:VCARDand between vCards - Missing
END:VCARD(parsed with a warning) - Properties with empty values
Each parsed VCard has a parseWarnings: ParseWarning[] field.
interface ParseWarning {
line?: number; // 1-based line number in the input, if known
message: string;
}Serialize the vCard to RFC 6350-compliant text. Throws VCardError if the card fails validation.
const text = vc.toString();Serialize without validation. Useful for inspecting or debugging partial/draft cards.
const text = vc.toStringLenient();Serialize one or multiple vCards to a single string.
import { stringify } from '@pipobscure/vcard';
const text = stringify([vc1, vc2]);interface GenerateOptions {
validate?: boolean; // default: true — throw VCardError on invalid cards
}- Line endings are always
\r\n(CRLF) per RFC 6350 §3.2. - Lines are folded at 75 octets (UTF-8 byte count), not 75 characters. Continuation lines begin with a single space.
VERSION:4.0is always the first property afterBEGIN:VCARD.- All text values are escaped (
\,,,;, newline) per RFC 6350 §3.4. - Parameter values that contain
:,;,,, or"are automatically quoted. - Properties are emitted in a consistent, human-readable order.
class VCard {
// Accumulates warnings from parsing; empty for programmatically built cards
parseWarnings: ParseWarning[];
// Version string from input (always '4.0' in generated output)
parsedVersion: string;
// ── Required (RFC cardinality 1*) ──────────────────────────────────────
fn: FNProperty[]; // at least one required
// ── Optional singular (RFC cardinality *1) ─────────────────────────────
n?: NProperty;
bday?: BDayProperty;
anniversary?: AnniversaryProperty;
gender?: GenderProperty;
prodid?: ProdIDProperty;
rev?: RevProperty;
uid?: UIDProperty;
kind?: KindProperty;
// ── Optional multiple (RFC cardinality *) ──────────────────────────────
nickname: NicknameProperty[];
photo: PhotoProperty[];
adr: AdrProperty[];
tel: TelProperty[];
email: EmailProperty[];
impp: IMPPProperty[];
lang: LangProperty[];
tz: TZProperty[];
geo: GeoProperty[];
title: TitleProperty[];
role: RoleProperty[];
logo: LogoProperty[];
org: OrgProperty[];
member: MemberProperty[];
related: RelatedProperty[];
categories: CategoriesProperty[];
note: NoteProperty[];
sound: SoundProperty[];
clientpidmap: ClientPidMapProperty[];
url: URLProperty[];
key: KeyProperty[];
fburl: FBURLProperty[];
caladruri: CALADRURIProperty[];
caluri: CALURIProperty[];
source: SourceProperty[];
xml: XMLProperty[];
// Unknown / extended / vendor properties (X-, unrecognised IANA)
extended: UnknownProperty[];
}vc.displayName // string — value of the most-preferred FN property
vc.primaryEmail // string | undefined — most-preferred email address
vc.primaryTel // string | undefined — most-preferred telephone"Most preferred" means the property with the lowest PREF parameter value (1 = highest preference). If no PREF is set, the first property in the list is used.
Quick-create a valid vCard with a single formatted name.
const vc = VCard.create('Bob Builder');
vc.email.push(new EmailProperty('bob@example.com'));Add any property to the correct typed field on the VCard.
vc.addProperty(new EmailProperty('alice@example.com'));
// equivalent to: vc.email.push(new EmailProperty('alice@example.com'))Return all properties as a flat array in logical order. Used internally by toString().
Validate without throwing.
const result = vc.validate();
if (!result.valid) {
for (const err of result.errors) {
console.error(`${err.property}: ${err.message}`);
}
}Deep-clone by round-tripping through serialization. Always produces a clean v4 vCard.
Simplified JSON representation (not full jCard / RFC 7095).
Every property class extends Property and exposes:
name: string— uppercase property name (e.g.'FN')group?: string— optional group label (e.g.'item1'in Apple exports)params: ParameterMap— raw parameter map (Map<string, string | string[]>)toContentLine(): string— serializes the value portion (used by the generator)
All property classes inherit these convenience getters/setters:
prop.type // string[] — TYPE parameter values, lowercased
prop.pref // number | undefined — PREF parameter (1–100, 1 = most preferred)
prop.language // string | undefined — LANGUAGE parameter (BCP 47 tag)
prop.altid // string | undefined — ALTID parameter
prop.pid // string | undefined — PID parameter
prop.valueType // string | undefined — VALUE parameter (e.g. 'uri', 'text')Setting a value to undefined removes the parameter:
prop.type = ['work', 'voice'];
prop.pref = 1;
prop.language = 'en';
prop.language = undefined; // removes LANGUAGE parameterCardinality: 1* (required, one or more)
class FNProperty extends TextProperty {
value: string;
}
new FNProperty('Alice Example')
new FNProperty('Alice Example', params, group)A vCard must have at least one FN. Multiple FN properties may be given to provide alternate language versions using ALTID and LANGUAGE parameters:
const fn1 = new FNProperty('山田太郎');
fn1.altid = '1';
fn1.language = 'ja';
const fn2 = new FNProperty('Yamada Taro');
fn2.altid = '1';
fn2.language = 'en';Cardinality: *1 (optional, at most one)
class NProperty extends Property {
value: StructuredName;
}
interface StructuredName {
familyNames: string[]; // e.g. ['Smith']
givenNames: string[]; // e.g. ['John']
additionalNames: string[]; // e.g. ['Q.']
honorificPrefixes: string[]; // e.g. ['Dr.']
honorificSuffixes: string[]; // e.g. ['Jr.', 'PhD']
}Each component is a list to support multiple values (e.g. compound surnames). The SORT-AS parameter provides a sort key:
const n = new NProperty({
familyNames: ['van der Berg'],
givenNames: ['Jan'],
additionalNames: [],
honorificPrefixes: [],
honorificSuffixes: [],
});
n.params.set('SORT-AS', 'Berg,Jan');Cardinality: *
class NicknameProperty extends TextListProperty {
values: string[];
}
new NicknameProperty(['Johnny', 'The Genius'])Cardinality: *
The value is a URI. In v4, inline data is expressed as a data: URI.
class PhotoProperty extends UriProperty {
value: string;
mediatype?: string; // MEDIATYPE parameter
}
new PhotoProperty('https://example.com/alice.jpg')
new PhotoProperty('data:image/jpeg;base64,/9j/4AA...')Cardinality: *1
class BDayProperty extends Property {
dateValue: DateAndOrTime | null; // parsed date, or null if VALUE=text
textValue?: string; // present when VALUE=text
}
// Parsed from a date string
BDayProperty.fromText('19900315') // full date
BDayProperty.fromText('--0315') // month+day, no year
BDayProperty.fromText('1990') // year only
// VALUE=text for approximate dates
BDayProperty.fromText('circa 1800', new Map([['VALUE', 'text']]))
// From a typed value
new BDayProperty({ year: 1990, month: 3, day: 15, hasTime: false })
new BDayProperty('circa 1800') // stores as textValueCardinality: *1. Identical structure to BDayProperty.
Cardinality: *1
class GenderProperty extends Property {
value: Gender;
}
interface Gender {
sex: GenderSex; // 'M' | 'F' | 'O' | 'N' | 'U' | ''
identity?: string; // free-form identity text
}
new GenderProperty({ sex: 'M' })
new GenderProperty({ sex: 'O', identity: 'non-binary' })
new GenderProperty({ sex: '', identity: 'it/its' })Sex values per RFC 6350:
| Value | Meaning |
|---|---|
M |
Male |
F |
Female |
O |
Other |
N |
None or not applicable |
U |
Unknown |
'' |
Not specified (use with identity text) |
Cardinality: *
class AdrProperty extends Property {
value: Address;
label?: string; // LABEL parameter — delivery label text
cc?: string; // CC parameter — ISO 3166-1 country code
}
interface Address {
postOfficeBox: string;
extendedAddress: string;
streetAddress: string;
locality: string; // city
region: string; // state/province
postalCode: string;
countryName: string;
}
const adr = new AdrProperty({
postOfficeBox: '',
extendedAddress: 'Suite 100',
streetAddress: '1 Infinite Loop',
locality: 'Cupertino',
region: 'CA',
postalCode: '95014',
countryName: 'USA',
});
adr.type = ['work'];
adr.label = '1 Infinite Loop\nCupertino, CA 95014\nUSA';Cardinality: *
In v4, telephone values should be URIs (using tel: or sip: schemes). Plain text values are also accepted for compatibility.
class TelProperty extends Property {
value: string;
isUri: boolean; // true when value is a URI
}
new TelProperty('tel:+1-555-123-4567') // URI (recommended)
new TelProperty('+1 555 123 4567') // text (tolerated)Well-known TYPE values: voice, fax, cell, video, pager, textphone, text, work, home.
Cardinality: *
class EmailProperty extends TextProperty {
value: string;
}
const email = new EmailProperty('alice@example.com');
email.type = ['work'];
email.pref = 1;Cardinality: *. Value is a URI (e.g. xmpp:alice@example.com, sip:alice@example.com).
Cardinality: *. Value is a BCP 47 language tag.
const lang = new LangProperty('fr');
lang.pref = 1;Cardinality: *
class TZProperty extends Property {
value: string;
valueKind: 'utc-offset' | 'uri' | 'text';
}
TZProperty.fromText('-0500') // UTC offset → valueKind: 'utc-offset'
TZProperty.fromText('-05:00') // colon format UTC offset
TZProperty.fromText('America/New_York', // IANA name → valueKind: 'text'
new Map([['VALUE', 'text']]))
TZProperty.fromText('https://...', ...) // URI → valueKind: 'uri'Cardinality: *. Value is a geo: URI.
class GeoProperty extends Property {
uri: string;
readonly coordinates: { latitude: number; longitude: number } | undefined;
}
// From coordinates
const geo = GeoProperty.fromCoordinates(37.386013, -122.082932);
// geo.uri === 'geo:37.386013,-122.082932'
// From URI string
const geo = new GeoProperty('geo:51.5074,-0.1278');
console.log(geo.coordinates); // { latitude: 51.5074, longitude: -0.1278 }Cardinality: *. Both are text properties.
new TitleProperty('Software Engineer')
new RoleProperty('Lead Developer')Cardinality: *. URI value (same as PhotoProperty).
Cardinality: *
class OrgProperty extends Property {
value: Organization;
}
interface Organization {
name: string;
units: string[]; // organizational units (zero or more)
}
new OrgProperty({ name: 'Acme Corp', units: [] })
new OrgProperty({ name: 'Acme Corp', units: ['Engineering', 'Platform'] })The SORT-AS parameter provides a sort key for the name and units.
Cardinality: *. URI value. Used in KIND:group cards to list members.
new MemberProperty('urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6')
new MemberProperty('mailto:bob@example.com')Cardinality: *. May be a URI or text (VALUE=text).
class RelatedProperty extends Property {
value: string;
isUri: boolean;
}
RelatedProperty.fromText('urn:uuid:...', params) // URI
RelatedProperty.fromText('Jane Doe', // text
new Map([['VALUE', 'text'], ['TYPE', 'spouse']]))Well-known TYPE values: contact, acquaintance, friend, met, co-worker, colleague, co-resident, neighbor, child, parent, sibling, spouse, kin, muse, crush, date, sweetheart, me, agent, emergency.
Cardinality: *. Value is a comma-separated list of text tags.
new CategoriesProperty(['friend', 'colleague', 'vip'])Cardinality: *. Text value; newlines are encoded as \n in the vCard text.
Cardinality: *1. Should identify the software that created the vCard.
vc.prodid = new ProdIDProperty('-//My App//My App 1.0//EN');Cardinality: *1. Stored as a JavaScript Date, or a raw string if parsing failed.
class RevProperty extends Property {
value: Date | string;
}
vc.rev = new RevProperty(new Date());
// Serialized as: REV:20240615T103000ZCardinality: *. URI value.
Cardinality: *1. Typically a urn:uuid: URI, but may be any URI or text.
vc.uid = new UIDProperty('urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6');When the value looks like a URI (has a scheme), it is serialized without text escaping.
Cardinality: *. Used for synchronisation between CardDAV clients. The value is a semicolon-separated pair of a PID number and a URI.
class ClientPidMapProperty extends Property {
value: ClientPidMap;
}
interface ClientPidMap {
pid: number;
uri: string;
}
new ClientPidMapProperty({ pid: 1, uri: 'urn:uuid:...' })Cardinality: *. URI value.
Cardinality: *. May be a URI or inline base64-encoded data.
class KeyProperty extends Property {
value: string;
isUri: boolean;
}
KeyProperty.fromText('http://example.com/key.pgp',
new Map([['VALUE', 'uri'], ['TYPE', 'work']]))Cardinality: *. URI value.
Cardinality: *. URI value. Used to schedule meetings with the contact.
Cardinality: *. URI value.
Cardinality: *1. Classifies the vCard object.
vc.kind = new KindProperty('individual'); // default
vc.kind = new KindProperty('group'); // distribution list
vc.kind = new KindProperty('org'); // organisation
vc.kind = new KindProperty('location'); // placeCardinality: *. URI indicating where the vCard data can be fetched.
Cardinality: *. Extends vCard with XML data. The value is escaped text containing XML.
Any property not listed in RFC 6350 — including X- vendor extensions, proprietary Apple/Google/Outlook properties, and unknown IANA properties — is stored as an UnknownProperty in vcard.extended. This ensures round-trip fidelity.
class UnknownProperty extends Property {
rawValue: string; // the raw, uninterpreted value string
}
// Example: Apple-specific X-ABLabel grouped with an email
// item1.EMAIL:john@example.com
// item1.X-ABLabel:Work
const label = vc.extended.find(p => p.name === 'X-ABLABEL' && p.group === 'item1');
console.log(label?.rawValue); // 'Work'Unknown properties are serialized back verbatim, preserving groups and parameters.
RFC 6350 §4.3 defines several date/time formats. The library uses the DateAndOrTime interface for structured representation.
interface DateAndOrTime {
year?: number;
month?: number;
day?: number;
hour?: number;
minute?: number;
second?: number;
utcOffset?: string; // 'Z', '+HH:MM', '-HH:MM', '+HHMM', etc.
hasTime: boolean;
}Parse any RFC 6350 date/time string.
import { parseDateAndOrTime } from '@pipobscure/vcard';
parseDateAndOrTime('19900315')
// { year: 1990, month: 3, day: 15, hasTime: false }
parseDateAndOrTime('--0315')
// { year: undefined, month: 3, day: 15, hasTime: false }
// (birthday where year is not known)
parseDateAndOrTime('1990')
// { year: 1990, hasTime: false }
parseDateAndOrTime('20090808T1430-0500')
// { year: 2009, month: 8, day: 8, hour: 14, minute: 30, utcOffset: '-0500', hasTime: true }
parseDateAndOrTime('20240101T120000Z')
// { year: 2024, month: 1, day: 1, hour: 12, minute: 0, second: 0, utcOffset: 'Z', hasTime: true }Returns null if the string is empty or completely unparseable.
Serialize a DateAndOrTime back to RFC 6350 text.
These are exported for advanced use; the library handles them automatically during parsing and generation.
import {
escapeText,
escapeStructuredComponent,
unescapeText,
parseStructured,
parseList,
parseStructuredList,
needsParamQuoting,
quoteParamValue,
unquoteParamValue,
splitStructured,
splitList,
} from '@pipobscure/vcard';escapeText('Smith, John; Jr.')
// 'Smith\\, John\\; Jr.'
unescapeText('Smith\\, John\\; Jr.')
// 'Smith, John; Jr.'
unescapeText('Line one\\nLine two')
// 'Line one\nLine two'Splitting respects backslash escapes, so an escaped delimiter is not treated as a component boundary.
// Semicolon-separated (N, ADR, ORG, GENDER)
parseStructured('Smith;John;Q.;Dr.;')
// ['Smith', 'John', 'Q.', 'Dr.', '']
// Comma-separated (NICKNAME, CATEGORIES)
parseList('friend,colleague,vip')
// ['friend', 'colleague', 'vip']
// Structured-with-lists (N honorific-suffixes: "ing. jr,M.Sc.")
parseStructuredList('Smith;Simon;;;ing. jr,M.Sc.')
// [['Smith'], ['Simon'], [], [], ['ing. jr', 'M.Sc.']]needsParamQuoting('work,voice') // true
needsParamQuoting('work') // false
quoteParamValue('work,voice') // '"work,voice"'
quoteParamValue('work') // 'work'
unquoteParamValue('"work,voice"') // 'work,voice'Thrown by vcard.toString() when the card fails strict validation. Has an optional property field naming the offending property.
import { VCardError } from '@pipobscure/vcard';
try {
const text = vc.toString();
} catch (err) {
if (err instanceof VCardError) {
console.error(`Validation failed on ${err.property}: ${err.message}`);
}
}| Rule | Detail |
|---|---|
FN required |
At least one FN property must be present (cardinality 1*). |
PREF range |
PREF parameter must be an integer between 1 and 100 inclusive. |
GENDER sex |
Must be one of M, F, O, N, U, or empty string. |
REV validity |
If a Date object is stored, it must not be NaN. |
Non-throwing alternative to toString() for checking validity.
interface ValidationResult {
valid: boolean;
errors: ValidationError[];
}
interface ValidationError {
property: string;
message: string;
}This library targets RFC 6350. All properties defined in §6 are implemented as typed classes with correct cardinality semantics, value type parsing, and serialization.
Lines are folded at 75 octets (UTF-8 bytes), not 75 characters. This is significant for non-ASCII content: a line of 25 three-byte characters (e.g. CJK) reaches the limit even though it is only 25 characters long.
The fold indicator (a single space on the continuation line) is stripped during unfolding. To preserve a word boundary across a fold, include the space as the last character of the preceding segment:
FN:A very long name that spans \r\n
multiple lines\r\n
After unfolding: A very long name that spans multiple lines.
In TEXT value types, the following characters are escaped on output and unescaped on input:
| Sequence | Meaning |
|---|---|
\\ |
Literal backslash |
\n or \N |
Newline (U+000A) |
\, |
Literal comma |
\; |
Literal semicolon |
Note that colons do not need escaping in property values (the parser finds the first colon to split name from value).
The TYPE parameter may be specified in two equivalent ways, both of which are handled:
TEL;TYPE=work;TYPE=voice:...
TEL;TYPE="work,voice":...
TEL;TYPE=WORK,VOICE:... (v3 style — tolerated)
All three produce prop.type === ['work', 'voice'].
The parser accepts v3.0 and v2.1 input:
ENCODING=QUOTED-PRINTABLEvalues are decoded (with correct multi-byte UTF-8 support).ENCODING=b(base64) is stripped; the value is stored as-is.CHARSETparameters are accepted and ignored (the library assumes UTF-8 throughout).- The
LABELproperty (removed in v4) is stored as anUnknownProperty. - Bare type tokens without
=(TEL;WORK;VOICE:...) are interpreted asTYPEvalues.
Apple Contacts uses a grouping mechanism to associate related properties:
item1.EMAIL;type=INTERNET:john@example.com
item1.X-ABLabel:Work
The group field on the property is set to 'item1'. Grouped properties are preserved on round-trip.
// Value types
type ValueType = 'text' | 'uri' | 'date' | 'time' | 'date-time' |
'date-and-or-time' | 'timestamp' | 'boolean' |
'integer' | 'float' | 'utc-offset' | 'language-tag';
type TypeValue = 'work' | 'home' | 'voice' | 'fax' | 'cell' | 'video' |
'pager' | 'textphone' | 'text' | 'contact' | 'friend' |
'spouse' | 'child' | 'parent' | /* ... */ | string;
type KindValue = 'individual' | 'group' | 'org' | 'location' | string;
type GenderSex = 'M' | 'F' | 'O' | 'N' | 'U' | '';
// Structured values
interface StructuredName { familyNames, givenNames, additionalNames,
honorificPrefixes, honorificSuffixes }
interface Address { postOfficeBox, extendedAddress, streetAddress,
locality, region, postalCode, countryName }
interface Organization { name, units }
interface Gender { sex, identity? }
interface ClientPidMap { pid, uri }
interface DateAndOrTime { year?, month?, day?, hour?, minute?, second?,
utcOffset?, hasTime }
// Parameter map
type ParameterMap = Map<string, string | string[]>;MIT