Skip to content

IPv4 ↔ IPv6 Converter — TypeScript 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 TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

// IPv4 ↔ IPv6 conversion - pure, deterministic logic. No DOM, no React. 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.

/** Options for embedding an IPv4 octet quad into an IPv6 address. */
export interface Ipv4ToIpv6Options {
  /**
   * Embedding family. `mapped` (default) → `::ffff:a.b.c.d`; `compatible` →
   * `::a.b.c.d`. Ignored when `prefix` is supplied.
   */
  mode?: 'mapped' | 'compatible';
  /**
   * 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`.
   */
  prefix?: string;
}

const HEX = /^[0-9a-fA-F]{1,4}$/;
const DEC3 = /^\d{1,3}$/;

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

/** Lowercase hex for one 16-bit group, with no leading zeros. */
const hexGroup = (v: number): string => 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.
 */
export function parseIpv4(s: string): number[] | null {
  const parts = s.trim().split('.');
  if (parts.length !== 4) return null;
  const octets: number[] = [];
  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. */
export function ipv4ToString(octets: number[]): string {
  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.
 */
export function parseIpv6(s: string): number[] | null {
  const input = s.trim();
  if (!input) return null;
  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: number[] = [];
    for (const g of headTokens) {
      if (!HEX.test(g)) return null;
      head.push(parseInt(g, 16));
    }
    const tail: number[] = [];
    for (let i = 0; i < tailTokens.length; i++) {
      const g = tailTokens[i];
      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 and parse, allowing a dotted-quad only in the last slot.
  const tokens = input.split(':');
  const groups: number[] = [];
  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. */
const isMapped = (g: number[]): boolean =>
  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. */
const isCompatible = (g: number[]): boolean =>
  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.
 */
function compressGroups(groups: number[]): string {
  let bestStart = -1;
  let bestLen = 0;
  let curStart = -1;
  let curLen = 0;
  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`.
 */
function renderWithEmbeddedTail(high: number[], octets: number[]): string {
  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.
 */
function renderCanonical(groups: number[]): string {
  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. */
export function ipv6ToString(groups: number[]): string {
  if (!isValidIpv6Groups(groups)) return '';
  return renderCanonical(groups);
}

/** Expand an IPv6 string to its full eight-group, four-hex-digit form; `''` if invalid. */
export function expandIpv6(s: string): string {
  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. */
export function compressIpv6(s: string): string {
  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.
 */
export function ipv4ToIpv6(octets: number[], opts?: Ipv4ToIpv6Options): string {
  if (
    !Array.isArray(octets) ||
    octets.length !== 4 ||
    !octets.every((o) => Number.isInteger(o) && o >= 0 && o <= 255)
  ) {
    return '';
  }
  const hi = (octets[0] << 8) | octets[1];
  const lo = (octets[2] << 8) | octets[3];

  if (opts?.prefix !== undefined) {
    const p = parseIpv6(opts.prefix);
    if (!p) return '';
    return renderWithEmbeddedTail(p.slice(0, 6), octets);
  }
  if (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).
 */
export function ipv6ToIpv4(s: string): string | null {
  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('.');
}

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 →