Before this lesson

Understand JSON, object validation, exceptions and local-storage recovery.

What you will be able to do

Convert unreliable input into an explicit schema and protect migrations with repeatable checks.

Understand the mechanism

Successful JSON parsing checks syntax, not the application schema. Data can be old, user-edited or partly damaged. Separate parsing, validation, migration and persistence so conversion can be inspected before any write.

Version the stored format. A former string array may become records with IDs and completion state. Keep migration pure and deterministic, without DOM, storage writes or randomness, so behavior can be tested directly.

Choose an explicit error policy. This example filters invalid entries but rejects unknown versions to avoid overwriting newer data. Retain the original serialized value until migration and saving succeed; do not clear all storage on failure.

Test compatibility and idempotent normalization. IDs must be unique and duplicate handling deliberate. This example assigns deterministic IDs to old string entries and does not attempt cross-device record merging.

Runnable example

Run the JavaScript in the Console of a separate practice page. Re-run the complete example after changing its input.

function migrate(input) {
  if (!input || typeof input !== 'object') throw new Error('Invalid envelope');
  if (input.version === 1 && Array.isArray(input.tasks)) {
    const tasks = input.tasks.filter(x => typeof x === 'string' && x.trim()).map((text,i) => ({id:i+1,text:text.trim(),done:false}));
    return {version:2,tasks};
  }
  if (input.version === 2 && Array.isArray(input.tasks)) {
    const seen = new Set();
    const tasks = input.tasks.filter(x => {
      if (!x || !Number.isSafeInteger(x.id) || x.id < 1 || seen.has(x.id) || typeof x.text !== 'string' || !x.text.trim() || typeof x.done !== 'boolean') return false;
      seen.add(x.id);return true;
    }).map(x => ({id:x.id,text:x.text.trim(),done:x.done}));
    return {version:2,tasks};
  }
  throw new Error('Unsupported version');
}
const converted=migrate({version:1,tasks:[' HTML ','',42,'CSS']});
console.log(converted);
if(JSON.stringify(migrate(converted))!==JSON.stringify(converted))throw new Error('Not idempotent');
let rejected=false;try{migrate({version:99,tasks:[]});}catch{rejected=true;}
if(!rejected)throw new Error('Unknown version accepted');
console.log('Migration checks passed');

Trace and verify

Old data becomes HTML and CSS with IDs one and two and false completion. Reapplying normalization is unchanged; unknown versions throw. Add duplicate IDs, string booleans and empty text to version two and verify filtering without mutating the input.

Common mistakes and boundaries

Add product-specific item and text limits for real storage. Silent filtering is unsuitable when every record needs traceability; return detailed validation errors instead.

Practice challenge

Return warnings describing discarded records and reasons while preserving rejection of unknown versions and the original data.

Reference solution and reasoning

Return explicit reasons from record validation and accumulate warnings by input position. Keep normalized data and warnings separate, and test both rather than only the final length.

Lab: migration should tolerate repeated loading

Blindly rewriting every record during startup can corrupt already-upgraded data. Use a pure migration function that checks the version and returns a new representation. This lab does not access localStorage, keeping migration correctness separate from storage availability.

Run this separate experiment in a modern browser Console; it is not a replacement for the main example above.

function migrate(value) {
  if (!value || typeof value !== 'object') throw new Error('Invalid envelope');
  if (value.version !== 1 && value.version !== 2) throw new Error('Unsupported version');
  if (!Array.isArray(value.items)) throw new Error('Invalid items');
  const items = value.items.map(item => {
    if (!item || typeof item.title !== 'string') throw new Error('Invalid title');
    if (value.version === 1) {
      if (typeof item.done !== 'boolean') throw new Error('Invalid done');
      return {title: item.title, stage: item.done ? 'done' : 'todo'};
    }
    if (!['todo', 'doing', 'done'].includes(item.stage)) throw new Error('Invalid stage');
    return {title: item.title, stage: item.stage};
  });
  return {version: 2, items};
}
const old = {version: 1, items: [{title: 'HTML', done: false}]};
const next = migrate(old);
console.log(JSON.stringify(next));
console.assert(JSON.stringify(migrate(next)) === JSON.stringify(next));
console.assert(old.items[0].done === false && !('stage' in old.items[0]));

Expected observations

The result is {"version":2,"items":[{"title":"HTML","stage":"todo"}]}. Loading the migrated value again gives the same representation; the original object is not mutated.

Why this happens and where it stops applying

Idempotence here concerns the resulting representation, not object identity. Reject unknown future versions rather than treating them as old data. A production schema also needs ID uniqueness and size limits; this lab isolates version and state handling. Validate the complete new object before attempting persistence. If writing fails, retain the old stored value and make the unsaved state visible.

Extension challenge

Why should a legacy done value of the string "false" not be tested by truthiness?

Reasoned solution

A nonempty string is truthy and would mark the task done. Reject values outside the contract. If a known historical format used strings, add explicit mappings for "true" and "false" and reject other strings.

Further reading