DEV Community

Cover image for Een Type-inferentiesysteem Bouwen voor Rommelige Webdata
circobit
circobit

Posted on

Een Type-inferentiesysteem Bouwen voor Rommelige Webdata

Webtabellen zijn strings. Alles is een string. Maar wanneer je exporteert naar JSON of SQL, wil je:

  • "1.234,56"1234.56 (getal)
  • "2024-03-15" → datumtype
  • "Yes"true (boolean)
  • "N/A"null

Een betrouwbaar type-inferentiesysteem bouwen betekent omgaan met de chaos van echte data. Hier lees je hoe ik het heb aangepakt voor HTML Table Exporter.

Het Probleem: Ambiguïteit Overal

Beschouw deze waarden:

Waarde Kan zijn...
"1,234" Getal 1234 (VS) of 1.234 (EU)
"01/02/03" 1 feb 2003 (VS) of 2 jan 2003 (EU) of 2001-02-03
"1" Integer 1 of boolean true
"N/A" String of null
"0" Integer 0 of boolean false

Type-inferentie gaat niet over parsen — het gaat over het raden van intentie uit ambigue aanwijzingen.

Architectuur: Inferentie op Kolomniveau

Types per cel afleiden is onbetrouwbaar. De waarde "1" alleen kan alles zijn. Maar een kolom met ["1", "2", "3", "4"] is duidelijk integers.

Mijn aanpak:

  1. Neem waarden uit de kolom (maximaal 100)
  2. Leid het type af voor elke niet-null waarde
  3. Aggregeer om het kolomtype te bepalen
  4. Pas een 90%-drempel toe — als 90%+ waarden overeenkomen met een type, gebruik dat
const DATA_TYPES = {
  STRING: "string",
  INTEGER: "integer", 
  NUMBER: "number",
  BOOLEAN: "boolean",
  DATE: "date",
  NULL: "null",
};

function inferColumnType(columnValues, maxSamples = 100) {
  const nonNullValues = columnValues.filter(v => v != null && v !== "");

  if (nonNullValues.length === 0) {
    return { type: DATA_TYPES.STRING, confidence: 0 };
  }

  const sample = nonNullValues.slice(0, maxSamples);

  // Tel voorkomens van elk type
  const typeCounts = {
    [DATA_TYPES.INTEGER]: 0,
    [DATA_TYPES.NUMBER]: 0,
    [DATA_TYPES.BOOLEAN]: 0,
    [DATA_TYPES.DATE]: 0,
    [DATA_TYPES.STRING]: 0,
  };

  for (const val of sample) {
    const type = inferValueType(val);
    if (type === DATA_TYPES.INTEGER) {
      // INTEGER is een subset van NUMBER
      typeCounts[DATA_TYPES.INTEGER]++;
      typeCounts[DATA_TYPES.NUMBER]++;
    } else if (type !== DATA_TYPES.NULL) {
      typeCounts[type]++;
    }
  }

  const total = sample.length;
  const threshold = 0.9;

  // Controleer types in prioriteitsvolgorde
  if (typeCounts[DATA_TYPES.BOOLEAN] >= total * threshold) {
    return { type: DATA_TYPES.BOOLEAN, confidence: typeCounts[DATA_TYPES.BOOLEAN] / total };
  }
  if (typeCounts[DATA_TYPES.DATE] >= total * threshold) {
    return { type: DATA_TYPES.DATE, confidence: typeCounts[DATA_TYPES.DATE] / total };
  }
  if (typeCounts[DATA_TYPES.INTEGER] >= total * threshold) {
    return { type: DATA_TYPES.INTEGER, confidence: typeCounts[DATA_TYPES.INTEGER] / total };
  }
  if (typeCounts[DATA_TYPES.NUMBER] >= total * threshold) {
    return { type: DATA_TYPES.NUMBER, confidence: typeCounts[DATA_TYPES.NUMBER] / total };
  }

  return { type: DATA_TYPES.STRING, confidence: 1 };
}
Enter fullscreen mode Exit fullscreen mode

Waarom 90%? Echte data bevat ruis. Een kolom met 100 integers kan één "N/A" bevatten. 100% overeenkomst eisen is te streng.

Typedetectie op Waardeniveau

De inferValueType-functie handelt individuele waarden af:

function inferValueType(value) {
  if (value == null || value === "") {
    return DATA_TYPES.NULL;
  }

  const str = String(value).trim();
  if (str === "") return DATA_TYPES.NULL;

  // Boolean-controle
  const lowerStr = str.toLowerCase();
  if (["true", "false", "yes", "no", "ja", "nee"].includes(lowerStr)) {
    return DATA_TYPES.BOOLEAN;
  }

  // Datumcontrole (ISO-formaat heeft voorkeur)
  if (/^\d{4}-\d{2}-\d{2}/.test(str)) {
    return DATA_TYPES.DATE;
  }
  if (/^\d{1,2}[/\-\.]\d{1,2}[/\-\.]\d{2,4}$/.test(str)) {
    return DATA_TYPES.DATE;
  }

  // Integer-controle (strikt)
  if (/^-?\d+$/.test(str)) {
    return DATA_TYPES.INTEGER;
  }

  // Getalcontrole (met decimalen en scheidingstekens)
  const cleanedForNumber = str
    .replace(/[$€£¥%\s]/g, "")
    .replace(/,/g, ".");

  if (/^-?\d+(\.\d+)?$/.test(cleanedForNumber)) {
    return DATA_TYPES.NUMBER;
  }

  return DATA_TYPES.STRING;
}
Enter fullscreen mode Exit fullscreen mode

De Uitdaging van Getalnormalisatie

Europese vs. Amerikaanse getalnotaties zijn de lastigste ambiguïteit:

Waarde VS-interpretatie EU-interpretatie
"1,234" 1234 1.234
"1.234" 1.234 1234
"1,234.56" 1234.56 Ongeldig
"1.234,56" Ongeldig 1234.56

Mijn heuristiek voor formaatdetectie:

function normalizeNumber(value) {
  if (value == null) return value;

  let str = String(value).trim();

  // Verwijder valutasymbolen en witruimte
  str = str.replace(/[$€£¥\s]/g, "");

  // Percentage afhandelen
  const isPercent = str.endsWith("%");
  if (isPercent) str = str.slice(0, -1);

  // Detecteer formaat door scheidingstekens te analyseren
  const commaCount = (str.match(/,/g) || []).length;
  const dotCount = (str.match(/\./g) || []).length;
  const lastComma = str.lastIndexOf(",");
  const lastDot = str.lastIndexOf(".");

  let normalized;

  if (commaCount === 0 && dotCount === 0) {
    // Gewoon integer: "1234"
    normalized = str;
  } else if (commaCount === 0 && dotCount === 1) {
    // "1.234" (EU duizendtal) of "1.23" (decimaal)
    // Heuristiek: 3 cijfers na punt = duizendtalscheidingsteken
    const afterDot = str.slice(lastDot + 1);
    if (afterDot.length === 3 && /^\d+$/.test(afterDot)) {
      normalized = str.replace(".", "");
    } else {
      normalized = str;
    }
  } else if (commaCount === 1 && dotCount === 0) {
    // "1,234" (VS duizendtal) of "1,23" (EU decimaal)
    const afterComma = str.slice(lastComma + 1);
    if (afterComma.length === 3 && /^\d+$/.test(afterComma)) {
      normalized = str.replace(",", "");
    } else {
      normalized = str.replace(",", ".");
    }
  } else if (lastDot > lastComma) {
    // "1,234.56" - VS-formaat
    normalized = str.replace(/,/g, "");
  } else if (lastComma > lastDot) {
    // "1.234,56" - EU-formaat
    normalized = str.replace(/\./g, "").replace(",", ".");
  } else {
    // Ambigu, geef ongewijzigd terug
    return value;
  }

  const num = parseFloat(normalized);
  if (Number.isNaN(num)) return value;

  return isPercent ? num / 100 : num;
}
Enter fullscreen mode Exit fullscreen mode

Kernpunt: De positie en het aantal scheidingstekens lost de meeste ambiguïteit op. Het laatste scheidingsteken is meestal het decimaalscheidingsteken.

Boolean-detectie (Met Valkuilen)

Duidelijke booleans: "true", "false", "yes", "no", "ja", "nee"

Maar hoe zit het met "0" en "1"?

// Problematisch: Dit converteert belastingtarieven naar booleans
// Kolom: [0, 0.05, 0.1, 0.2]  -> [false, 0.05, 0.1, 0.2] 
Enter fullscreen mode Exit fullscreen mode

Mijn oplossing: Converteer puur numerieke waarden niet naar booleans.

function applyBooleanNormalization(rows, booleanConfig) {
  const trueValues = new Set(booleanConfig.true.map(v => v.toLowerCase()));
  const falseValues = new Set(booleanConfig.false.map(v => v.toLowerCase()));

  return rows.map((row, rowIndex) => {
    if (rowIndex === 0) return row; // Sla header over

    return row.map(cell => {
      if (cell == null) return cell;

      const cellStr = String(cell).toLowerCase().trim();

      // Sla over als puur numeriek (voorkomt 0 -> false in getalkolommen)
      if (/^-?\d+(\.\d+)?$/.test(cellStr)) {
        return cell;
      }

      if (trueValues.has(cellStr)) return "true";
      if (falseValues.has(cellStr)) return "false";

      return cell;
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Null-waarde-afhandeling

Webdata gebruikt veel representaties voor "geen waarde":

  • "" (lege string)
  • "N/A", "n/a", "NA"
  • "-", "--"
  • "null", "NULL"
  • "none", "None"
  • "." (komt voor in overheidsdata)

Configureerbare null-detectie:

function applyNullValues(rows, nullPatterns) {
  const nullSet = new Set(nullPatterns.map(v => v.toLowerCase().trim()));

  return rows.map((row, rowIndex) => {
    if (rowIndex === 0) return row; // Sla header over

    return row.map(cell => {
      if (cell == null) return null;

      const cellStr = String(cell).toLowerCase().trim();
      return nullSet.has(cellStr) ? null : cell;
    });
  });
}

// Gebruik
applyNullValues(rows, ["N/A", "n/a", "-", "--", "null", "none", "."]);
Enter fullscreen mode Exit fullscreen mode

De Pipeline

Volledige type-inferentie- en opschoningspipeline:

function cleanTable(tableInfo, config) {
  let rows = cloneRows(tableInfo.rows);

  // 1. Trim alle strings
  if (config.trimStrings) {
    rows = applyTrimStrings(rows);
  }

  // 2. Converteer null-patronen naar werkelijke null
  if (config.nullValues?.length) {
    rows = applyNullValues(rows, config.nullValues);
  }

  // 3. Normaliseer booleans (vóór getallen, om 0->false te voorkomen)
  if (config.booleans) {
    rows = applyBooleanNormalization(rows, config.booleans);
  }

  // 4. Normaliseer getallen (handelt EU/VS-formaten af)
  if (config.normalizeNumbers) {
    rows = applyNumberNormalization(rows);
  }

  // 5. Normaliseer datums (optioneel, formaat-specifiek)
  if (config.normalizeDates) {
    rows = applyDateNormalization(rows, config.dateFormat);
  }

  return { ...tableInfo, rows };
}
Enter fullscreen mode Exit fullscreen mode

Randgevallen Testen

Type-inferentie heeft veel randgevallen. Ik onderhoud een testsuite:

// Getalnormalisatietests
assertEqual(normalizeNumber("1,234.56"), 1234.56);    // VS-formaat
assertEqual(normalizeNumber("1.234,56"), 1234.56);    // EU-formaat
assertEqual(normalizeNumber("€1.234,56"), 1234.56);   // Met valutasymbool
assertEqual(normalizeNumber("45,5%"), 0.455);         // Percentage
assertEqual(normalizeNumber("1.234"), 1234);          // EU duizendtal
assertEqual(normalizeNumber("1.23"), 1.23);           // Decimaal

// Boolean-tests
assertEqual(inferValueType("Yes"), DATA_TYPES.BOOLEAN);
assertEqual(inferValueType("0"), DATA_TYPES.INTEGER);  // NIET boolean
assertEqual(inferValueType("0.5"), DATA_TYPES.NUMBER); // NIET boolean
Enter fullscreen mode Exit fullscreen mode

Integratie met Export

Bij export naar SQL gebruik je de afgeleide types:

function inferSqlColumnTypes(rows) {
  const header = rows[0];
  const dataRows = rows.slice(1);

  return header.map((_, colIndex) => {
    const columnValues = dataRows.map(row => row[colIndex]);
    const { type } = inferColumnType(columnValues);

    switch (type) {
      case DATA_TYPES.INTEGER: return "INTEGER";
      case DATA_TYPES.NUMBER: return "REAL";
      case DATA_TYPES.BOOLEAN: return "BOOLEAN";
      case DATA_TYPES.DATE: return "DATE";
      default: return "TEXT";
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Lessen Geleerd

  1. Kolomcontext overtreft celanalyse. Een enkele "1" is ambigu; een kolom integers niet.

  2. Wees conservatief met conversie. Het is beter iets als string te laten dan het te beschadigen met een verkeerde conversie.

  3. Maak het configureerbaar. Verschillende domeinen hebben verschillende null-waarden, boolean-representaties en getalnotaties.

  4. Test met echte data. Synthetische tests missen de chaos van werkelijke webtabellen.

Dit systeem drijft de dataopschoning in HTML Table Exporter aan. Bekijk hoe het kopiëren van tabellen naar Excel vaak resulteert in kapotte opmaak die goede type-inferentie kan oplossen.

De PRO-versie laat gebruikers deze regels per exportprofiel configureren. Meer informatie op gauchogrid.com/nl/html-table-exporter of probeer het in de Chrome Web Store.


Bouw je type-inferentie voor een ander domein? Ik hoor graag over jouw randgevallen.

Top comments (0)