Error handling#

This guide covers how Planning Engine reports failures in JavaScript: which problems throw, which come back as data inside a normal result, and how to keep a live project usable after each kind.

How failures surface#

Every Project method and schedule is synchronous. A call either returns its result or throws, so a plain try/catch around the call is all you need. There are no rejected promises to handle; the only async export, init(), never fails.

The library throws two error classes of its own. Both extend Error, and both are exported from the package:

Class name Meaning Project state after the throw
InputError "InputError" The data or options you passed are invalid. Unchanged. The previous accepted state and its cached result are kept.
ProjectError "ProjectError" The input may be fine, but the call doesn't fit the project's current state. Unchanged. Call the right method first, then retry.

Most of what goes wrong in a plan isn't an error at all. Dependency cycles, constraint conflicts, dropped links, a planner that didn't converge and rejected apply operations come back inside the result. A throw means the call itself couldn't run.

A few other throws exist: a disposed project, input that can't be converted (circular objects, BigInt), and an internal engine failure. Other throws covers them.

Catching errors#

Tell the two library errors apart with instanceof, then read kind and path on an InputError or code on a ProjectError. Rethrow anything else, so that bugs in your own code don't get reported as bad input:

import { InputError, ProjectError, schedule } from "plen-js";

function scheduleOrExplain(data) {
  try {
    return schedule(data);
  } catch (error) {
    if (error instanceof InputError) {
      console.error(`invalid input at ${error.path ?? "<input>"}: ${error.message}`);
      return null;
    }
    if (error instanceof ProjectError) {
      console.error(`project state: ${error.code}`);
      return null;
    }
    throw error;
  }
}

// Prints "invalid input at tasks[0].duration: invalid duration"
scheduleOrExplain({
  tasks: [{ id: 1, start_date: "2026-01-05 08:00", duration: -1 }],
});

Both classes are plain Error subclasses, so an error that passes through your own layers keeps its class and fields. To add context, wrap it with the standard cause option and keep the original for later checks:

import { InputError, Project } from "plen-js";

function loadPlan(project, data, planName) {
  try {
    return project.solve(data, { includeUnchanged: true });
  } catch (error) {
    if (error instanceof InputError) {
      throw new Error(`plan "${planName}" can't be loaded`, { cause: error });
    }
    throw error;
  }
}

const project = new Project();
try {
  loadPlan(project, { tasks: [{ id: 1, duration: 2 }] }, "Q3 launch");
} catch (error) {
  // error.cause is the InputError, with its kind and path.
  console.error(error.message, "-", error.cause.path, error.cause.message);
}

Input errors#

An InputError carries three fields besides name:

  • kind is "parse" or "validation". "parse" means the input doesn't have the expected shape: a wrong type (duration: "bad") or an unknown key in options or a change set. "validation" means the shape is right but a value isn't allowed.
  • message is a short description, such as invalid duration or dated constraint requires constraint_date.
  • path points at the offending field, in the form tasks[0].duration, tasks[0].constraint_date or pool.workers[1].resource. It's null for every "parse" error and for a few validation errors, such as a priority outside 0..1000.

With the default validation: "strict", a solve(data) or schedule call rejects the input on the first problem it finds. Strict validation rejects, among other things:

  • a leaf task with fewer than two of start_date, end_date and duration, or with all three when they don't agree
  • a dated constraint ("snet", "fnlt", ...) without a constraint_date
  • duplicate task, link or calendar ids (1 and "1" count as the same id)
  • an unknown parent, or a cycle in the parent hierarchy
  • a summary with start_date + duration (a summary takes both dates or neither)
  • a negative duration, a priority above 1000, a negative leveling_delay
  • an assignment to an unknown task or resource, or with value: 0
  • "week", "month" or "year" duration units, and calendars with no working time

The full list per field is on the reference pages for ProjectData, TaskData and AssignmentData.

Some values are rejected before the engine sees the data. While the library reads your objects, a number that isn't finite (NaN, Infinity) throws InputError("validation", "Numbers must be finite"), and an invalid Date throws InputError("validation", "Invalid Date"). For a non-finite number, path is only the property name (for example "duration"), not the full path to it. For an invalid Date, path is null.

Reporting a validation error with its path#

message is only the description. Read path yourself when you need to point the user at the field:

import { InputError, schedule } from "plen-js";

const data = {
  tasks: [{ id: 1, start_date: "2026-01-05 08:00", duration: 1, constraint_type: "snet" }],
};

try {
  schedule(data);
} catch (error) {
  if (!(error instanceof InputError)) throw error;
  // Prints "validation tasks[0].constraint_date: dated constraint requires constraint_date"
  console.error(error.kind, `${error.path ?? "<input>"}: ${error.message}`);
}

A rejected replacement keeps the old state#

On a live Project, a solve(data) that fails validation installs nothing. The project keeps its accepted baseline, and solve(null) still returns the result you had before:

import { InputError, Project } from "plen-js";

const project = new Project();
const first = project.solve({
  tasks: [{ id: 1, start_date: "2026-01-05 08:00", duration: 2 }],
});

try {
  project.solve({
    tasks: [{ id: 2, start_date: "2026-01-05 08:00", duration: -1 }],
  });
} catch (error) {
  if (!(error instanceof InputError)) throw error;
  console.error(`replacement rejected: ${error.message}`);
}

// Still the first data set.
const again = project.solve(null);
console.log(again.baselineId === first.baselineId); // true

Invalid project options#

The constructor validates its options too. An unknown dates value fails with path: "dates"; an unknown validation mode or an unknown option key also throws:

import { InputError, Project } from "plen-js";

try {
  new Project({ dates: "iso" });
} catch (error) {
  if (!(error instanceof InputError)) throw error;
  console.error(error.path, error.message); // "dates Unknown dates representation"
}
Note

new Project(options) throws an InputError if an option is unknown or has an invalid value.

Project errors#

A ProjectError tells you the call came at the wrong moment. Its code names the reason, and its message is the same string. Each code has one recovery:

code When it's thrown Recovery
"no-baseline" preview, loadProfile, evaluate, slots or suggestAssignments on a project with no solved baseline, including right after apply Call solve first. After apply, call solve(null) before any of these methods.
"stale-baseline" A baselineId in the options or in SchedulingChanges doesn't name the current baseline The project was solved, edited or leveled since you read the token. Take baselineId from the latest result and retry.
"needs-resync" solve(null), apply or level after an apply rejected an operation Send the full data again with solve(data). Nothing else is accepted until that call succeeds.

Telling the codes apart#

import { InputError, Project, ProjectError } from "plen-js";

function previewOrExplain(project, changes, baselineId) {
  try {
    const result = project.preview(changes, { baselineId });
    return `${result.updates.length} tasks would move`;
  } catch (error) {
    if (error instanceof ProjectError) {
      switch (error.code) {
        case "no-baseline":
          return "solve the project first";
        case "stale-baseline":
          return "the plan changed since you loaded it; refresh and try again";
        case "needs-resync":
          return "reload the full project";
      }
    }
    if (error instanceof InputError) {
      return `invalid change at ${error.path}: ${error.message}`;
    }
    throw error;
  }
}

// An unsolved project: prints "solve the project first".
console.log(previewOrExplain(new Project(), {}, undefined));

preview checks both options.baselineId and changes.baselineId. Baseline tokens are opaque and can differ between runs for the same data, so never store them across sessions or compare them in tests.

Recovering from needs-resync#

A rejected operation in apply doesn't throw. It's listed in the report's rejected array, and the report's needsResync is true. From then on, solve(null), apply and level throw ProjectError("needs-resync"), because your copy of the plan and the project's copy no longer agree. Resync with the full data you hold:

import { Project } from "plen-js";

function commit(project, changes, sourceOfTruth) {
  const report = project.apply(changes);
  if (report.needsResync) {
    for (const rejected of report.rejected) {
      console.error(`rejected ${rejected.path}: ${rejected.message}`);
    }
    return project.solve(sourceOfTruth);
  }
  return project.solve(null);
}

const data = { tasks: [{ id: 1, start_date: "2026-01-05 08:00", duration: 2 }] };
const project = new Project();
project.solve(data);

// Task 999 doesn't exist: the operation is rejected.
const result = commit(project, { tasks: { update: [{ id: 999, duration: 3 }] } }, data);
console.log(`resynced, baseline ${result.baselineId}`);

If the resync data itself fails validation, the call throws an InputError and the project still needs a resync. Fix the data and call solve(data) again. The recovery how-to covers this flow in an editor.

Results that are not errors#

These all come back in a normal result. Check them on every result you show to a user:

Where What it means
ScheduleResult.cycles Dependency cycles. A cyclic solve returns unchanged dates and empty updates, and still installs a baseline.
ScheduleResult.conflicts "constraint-violation" (a link and a hard constraint disagree, and the constraint wins) and "calendar-non-working"
ScheduleResult.notes Input the engine dropped ("dropped-link", "dropped-task", "dropped-assignment") or fixed ("repaired")
ScheduleResult.converged false when the planner didn't reach a fixed point within config.max_iterations. The dates are still valid output.
ApplyReport.rejected Operations that apply refused. The other operations in the change set were still applied.
CandidateResult.error An evaluate candidate whose change set was rejected. That candidate has no metrics; the other candidates are scored normally.
SuggestResult.unfilled / invalid Targeted empty slots that suggestAssignments left empty, and slots still held by a resource outside their allowed list. The rest of the proposal is still usable.

Checking a schedule for problems#

import { schedule } from "plen-js";

const result = schedule({
  tasks: [
    { id: "a", start_date: "2026-01-05 08:00", duration: 1 },
    { id: "b", start_date: "2026-01-05 08:00", duration: 1 },
  ],
  // a -> b -> a: a dependency cycle, reported as data.
  links: [
    { id: "l1", source: "a", target: "b" },
    { id: "l2", source: "b", target: "a" },
  ],
});

for (const cycle of result.cycles) {
  console.log("cycle through tasks", cycle.tasks);
}
for (const conflict of result.conflicts) {
  if (conflict.kind === "constraint-violation") {
    console.log(`task ${conflict.task}: required ${conflict.required}, got ${conflict.actual}`);
  }
}
if (!result.converged) {
  console.log("planner hit max_iterations");
}

Dependency cycles are accepted state. Cycles in the parent hierarchy are different: they're invalid input and throw an InputError at tasks[i].parent.

Repair instead of reject#

validation: "strict" suits data your own code produces. For data from a UI or an older store, new Project({ validation: "repair" }) coerces the input instead of throwing:

  • duplicate ids: the last record wins, at the first record's position
  • missing dates or durations are derived
  • in an inconsistent start/end/duration triple the dates win
  • dated constraints without a date are cleared
  • invalid pauses lists and assignment slice values are cleared

Each fix shows up as a "repaired" note with a path and a message, so you can show the user what changed. Repair mode still rejects an entirely undated project, and other problems (a negative duration, for example) still throw an InputError.

import { Project } from "plen-js";

const project = new Project({ validation: "repair" });
const result = project.solve({
  tasks: [
    { id: 1, start_date: "2026-01-05 08:00", duration: 1 },
    { id: 1, start_date: "2026-01-05 08:00", duration: 2 },
  ],
});

for (const note of result.notes) {
  if (note.kind === "repaired") {
    // "repaired tasks[1].id: duplicate id replaced the earlier record, retaining its position"
    console.log(`repaired ${note.path}: ${note.message}`);
  }
}

The messy input how-to shows the repair flow end to end.

Errors from slots#

slots throws the same two classes as every other method:

  • InputError with kind: "validation" for a bad pool or requirement. The path names the item: pool.workers[0].resource, requirements[2].count, requirements[1].assignments[0].
  • ProjectError("no-baseline") when the project has no clean solved baseline. Call solve before slots, including after apply.

So one catch covers the whole staffing flow, slots followed by suggestAssignments:

import { InputError, Project, ProjectError } from "plen-js";

function staff(project, pool, requirements) {
  const slots = project.slots(pool, requirements);
  return project.suggestAssignments(slots);
}

const pool = { workers: [{ resource: "dev1", skills: [{ id: "js" }] }] };
const requirements = [{ id: "frontend", task: 1, matches: [{ skill: "js" }] }];

try {
  // An unsolved project: slots throws ProjectError("no-baseline").
  const result = staff(new Project(), pool, requirements);
  console.log(`${result.steps.length} steps`);
} catch (error) {
  if (error instanceof InputError) {
    console.error(`${error.path}: ${error.message}`);
  } else if (error instanceof ProjectError) {
    console.error(`project state: ${error.code}`);
  } else {
    throw error;
  }
}
Note

slots throws InputError / ProjectError like the other Project methods.

Other throws#

A few failures don't use the library's error classes (other errors):

Thrown When What to do
Error("Project has been disposed") Any method call on a project after dispose() Don't use a project after disposing it. A clone made earlier is unaffected.
TypeError The input contains a circular reference or a BigInt, the same cases where JSON.stringify throws Pass plain data: convert BigInt ids to numbers or strings, and break cycles.
Error with name === "Panic" An internal engine check failed This is a bug in the library, not in your data. Report it with the input that caused it.
import { Project } from "plen-js";

const project = new Project();
project.dispose();

try {
  project.solve(null);
} catch (error) {
  console.error(error.message); // "Project has been disposed"
}
Note

dispose() is optional; after it, every call on that project throws Error("Project has been disposed").

Check error.name === "Panic" to tell an engine failure apart from your own bugs. It's an ordinary Error, not an exported class, so instanceof won't work for it.