Skip to content

IPv4 ↔ IPv6 Converter — JavaScript source

Convert between IPv4 and IPv6 addresses both ways. Parse and validate addresses, expand and compress IPv6 to its canonical RFC 5952 form, map an IPv4 into IPv4-mapped and IPv4-compatible IPv6 (or any custom /96 prefix), and extract an embedded IPv4 back out.

This is the JavaScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

// =============================================================================
//  ip-converter - IPv4 ↔ IPv6 conversion (polyglot showcase: JavaScript)
//  CosmoDev polyglot port of ip-converter, ported from src/lib/ip-converter.ts.
//  Display source - part of CosmoDev's polyglot tool pages.
//
//  Pure, deterministic IPv4/IPv6 address conversion logic. Every parse function
//  returns `null` (or '' for the string renderers) on invalid input instead of
//  throwing, so the UI can show a graceful error. IPv6 text follows RFC 5952:
//  lowercase hex, no leading zeros, the single longest run of zero groups
//  collapsed to `::`, and a dotted-decimal tail only for IPv4-mapped
//  (`::ffff:`) addresses.
//
//  This is the ES module variant of the canonical TypeScript lib: same exports,
//  same behavior, JSDoc in place of TS interfaces. Stdlib only.
// =============================================================================

/**
 * @typedef {Object} Ipv4ToIpv6Options
 * @property {'mapped'|'compatible'} [mode] - Embedding family. `mapped`
 *   (default) → `::ffff:a.b.c.d`; `compatible` → `::a.b.c.d`. Ignored when
 *   `prefix` is supplied.
 * @property {string} [prefix] - Optional custom high-96-bit prefix (a valid
 *   IPv6 string; its first 6 groups are used and its low 32 bits are
 *   overwritten by the IPv4). e.g. `64:ff9b::` yields a NAT64-style
 *   `64:ff9b::a.b.c.d`. Overrides `mode`.
 */

// 1-4 hex digits (a single IPv6 group, pre-`::`/post-`::` token).
const HEX = /^[0-9a-fA-F]{1,4}$/;
// 1-3 decimal digits (an IPv4 octet, before the 0-255 range check).
const DEC3 = /^\d{1,3}$/;

/**
 * Lowercase hex for one 16-bit group, with no leading zeros.
 * @param {number} v
 * @returns {string}
 */
const hexGroup = (v) => v.toString(16);

/**
 * Parse a dotted-decimal IPv4 string into four octets, validating each is 0-255.
 * Returns `null` for anything that is not exactly four numeric octets in range.
 * @param {string} s
 * @returns {number[] | null}
 */
export function parseIpv4(s) {
  const parts = s.trim().split('.');
  if (parts.length !== 4) return null;
  /** @type {number[]} */
  const octets = [];
  for (const p of parts) {
    if (!DEC3.test(p)) return null;
    const n = Number(p);
    if (n < 0 || n > 255) return null;
    octets.push(n);
  }
  return octets;
}

/**
 * Render four octets as `a.b.c.d`, or `''` if the octets are out of range.
 * @param {number[]} octets
 * @returns {string}
 */
export function ipv4ToString(octets) {
  if (
    !Array.isArray(octets) ||
    octets.length !== 4 ||
    !octets.every((o) => Number.isInteger(o) && o >= 0 && o <= 255)
  ) {
    return '';
  }
  return octets.join('.');
}

/**
 * Parse an IPv6 string (with `::` compression, hex groups, and an optional
 * dotted-decimal IPv4 tail for mapped/compatible forms) into eight 16-bit
 * groups. Returns `null` on any malformed input - never throws.
 * @param {string} s
 * @returns {number[] | null}
 */
export function parseIpv6(s) {
  const input = s.trim();
  if (!input) return null;
  // At most one `::` run is legal; reject ambiguous double-compression.
  if ((input.match(/::/g) || []).length > 1) return null;

  const dc = input.indexOf('::');
  if (dc >= 0) {
    const before = input.slice(0, dc);
    const after = input.slice(dc + 2);
    const headTokens = before === '' ? [] : before.split(':');
    const tailTokens = after === '' ? [] : after.split(':');

    const head = [];
    for (const g of headTokens) {
      if (!HEX.test(g)) return null;
      head.push(parseInt(g, 16));
    }
    const tail = [];
    for (let i = 0; i < tailTokens.length; i++) {
      const g = tailTokens[i];
      // A dotted-quad IPv4 tail is permitted only in the final slot, where it
      // contributes two groups (high octet pair, low octet pair).
      if (i === tailTokens.length - 1 && g.includes('.')) {
        const oct = parseIpv4(g);
        if (!oct) return null;
        tail.push((oct[0] << 8) | oct[1], (oct[2] << 8) | oct[3]);
      } else {
        if (!HEX.test(g)) return null;
        tail.push(parseInt(g, 16));
      }
    }
    const total = head.length + tail.length;
    if (total >= 8) return null; // `::` must elide at least one group
    return [...head, ...new Array(8 - total).fill(0), ...tail];
  }

  // No compression: split on ':' and parse, allowing a dotted-quad only in the
  // last slot. The result must be exactly eight groups.
  const tokens = input.split(':');
  const groups = [];
  for (let i = 0; i < tokens.length; i++) {
    const g = tokens[i];
    if (i === tokens.length - 1 && g.includes('.')) {
      const oct = parseIpv4(g);
      if (!oct) return null;
      groups.push((oct[0] << 8) | oct[1], (oct[2] << 8) | oct[3]);
    } else {
      if (!HEX.test(g)) return null;
      groups.push(parseInt(g, 16));
    }
  }
  return groups.length === 8 ? groups : null;
}

/**
 * True when the eight groups form an IPv4-mapped (`::ffff:`) address.
 * @param {number[]} g
 * @returns {boolean}
 */
const isMapped = (g) =>
  g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0xffff;

/**
 * True when the eight groups form an IPv4-compatible (`::`) address.
 * @param {number[]} g
 * @returns {boolean}
 */
const isCompatible = (g) =>
  g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0;

/**
 * Collapse the longest run (length ≥ 2) of zero groups into `::` (first run
 * wins on ties) and strip leading zeros - RFC 5952 canonical text for pure-hex
 * IPv6. Does not emit dotted-decimal; call `renderCanonical` for that.
 * @param {number[]} groups
 * @returns {string}
 */
function compressGroups(groups) {
  let bestStart = -1;
  let bestLen = 0;
  let curStart = -1;
  let curLen = 0;
  // Track the longest run of consecutive zero groups. `bestStart` records the
  // first run of the longest length encountered (strict `>` keeps earliest).
  for (let i = 0; i < groups.length; i++) {
    if (groups[i] === 0) {
      if (curStart < 0) curStart = i;
      curLen++;
      if (curLen > bestLen) {
        bestLen = curLen;
        bestStart = curStart;
      }
    } else {
      curStart = -1;
      curLen = 0;
    }
  }
  if (bestLen < 2) return groups.map(hexGroup).join(':');
  const before = groups.slice(0, bestStart).map(hexGroup).join(':');
  const after = groups.slice(bestStart + bestLen).map(hexGroup).join(':');
  return `${before}::${after}`;
}

/**
 * Render a compressed high part followed by a dotted-decimal IPv4 tail. When the
 * high part already ends in `::` (its zero run reaches the boundary) the IPv4
 * attaches directly; otherwise a single `:` separates them - so `::ffff:` →
 * `::ffff:a.b.c.d` and `::` → `::a.b.c.d`.
 * @param {number[]} high
 * @param {number[]} octets
 * @returns {string}
 */
function renderWithEmbeddedTail(high, octets) {
  const highStr = compressGroups(high);
  const ipv4 = octets.join('.');
  return highStr.endsWith('::') ? `${highStr}${ipv4}` : `${highStr}:${ipv4}`;
}

/**
 * Canonical RFC 5952 text for eight groups: a dotted-decimal tail for
 * IPv4-mapped (`::ffff:`) addresses, otherwise pure compressed hex. The
 * deprecated IPv4-compatible range (`::/96`) is NOT rendered dotted here - that
 * would mis-render the unspecified (`::`) and loopback (`::1`) addresses as
 * `::0.0.0.0` / `::0.0.0.1`. Compatible extraction is still available via
 * `ipv6ToIpv4`; on-demand compatible generation via `ipv4ToIpv6` is untouched.
 * @param {number[]} groups
 * @returns {string}
 */
function renderCanonical(groups) {
  if (isMapped(groups)) {
    const octets = [
      (groups[6] >> 8) & 0xff,
      groups[6] & 0xff,
      (groups[7] >> 8) & 0xff,
      groups[7] & 0xff,
    ];
    return renderWithEmbeddedTail(groups.slice(0, 6), octets);
  }
  return compressGroups(groups);
}

/**
 * Render eight groups as canonical compressed IPv6, or `''` if invalid.
 * @param {number[]} groups
 * @returns {string}
 */
export function ipv6ToString(groups) {
  if (!isValidIpv6Groups(groups)) return '';
  return renderCanonical(groups);
}

/**
 * Expand an IPv6 string to its full eight-group, four-hex-digit form; `''` if invalid.
 * @param {string} s
 * @returns {string}
 */
export function expandIpv6(s) {
  const g = parseIpv6(s);
  if (!g) return '';
  return g.map((v) => v.toString(16).padStart(4, '0')).join(':');
}

/**
 * Compress an IPv6 string to its RFC 5952 canonical form; `''` if invalid.
 * @param {string} s
 * @returns {string}
 */
export function compressIpv6(s) {
  const g = parseIpv6(s);
  if (!g) return '';
  return renderCanonical(g);
}

/**
 * Embed an IPv4 octet quad into an IPv6 address. By default produces the
 * IPv4-mapped form `::ffff:a.b.c.d`; `mode: 'compatible'` yields `::a.b.c.d`;
 * a `prefix` overrides both and places the IPv4 after any custom /96 prefix
 * (e.g. `64:ff9b::a.b.c.d`). Returns `''` for invalid octets or prefix.
 * @param {number[]} octets
 * @param {Ipv4ToIpv6Options} [opts]
 * @returns {string}
 */
export function ipv4ToIpv6(octets, opts) {
  if (
    !Array.isArray(octets) ||
    octets.length !== 4 ||
    !octets.every((o) => Number.isInteger(o) && o >= 0 && o <= 255)
  ) {
    return '';
  }

  if (opts && opts.prefix !== undefined) {
    const p = parseIpv6(opts.prefix);
    if (!p) return '';
    return renderWithEmbeddedTail(p.slice(0, 6), octets);
  }
  if (opts && opts.mode === 'compatible') {
    return renderWithEmbeddedTail([0, 0, 0, 0, 0, 0], octets);
  }
  return renderWithEmbeddedTail([0, 0, 0, 0, 0, 0xffff], octets);
}

/**
 * Extract the embedded IPv4 from an IPv4-mapped (`::ffff:a.b.c.d`) or
 * IPv4-compatible (`::a.b.c.d`) address, returning dotted-decimal or `null`
 * when the address carries no embedded IPv4 (or is unparseable).
 * @param {string} s
 * @returns {string | null}
 */
export function ipv6ToIpv4(s) {
  const g = parseIpv6(s);
  if (!g || !(isMapped(g) || isCompatible(g))) return null;
  const octets = [
    (g[6] >> 8) & 0xff,
    g[6] & 0xff,
    (g[7] >> 8) & 0xff,
    g[7] & 0xff,
  ];
  return octets.join('.');
}

/** True when `groups` is exactly eight integers in the 16-bit range. */
function isValidIpv6Groups(groups) {
  return (
    Array.isArray(groups) &&
    groups.length === 8 &&
    groups.every((v) => Number.isInteger(v) && v >= 0 && v <= 0xffff)
  );
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →