[Canonical page](<https://docs.dhtmlx.com/planning-engine/js/error-handling/>)
[Full documentation index](<https://docs.dhtmlx.com/planning-engine/llms.txt>)


# 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`](<https://docs.dhtmlx.com/planning-engine/js/api/project.md>) method and [`schedule`](<https://docs.dhtmlx.com/planning-engine/js/api/schedule.md>) 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()`](<https://docs.dhtmlx.com/planning-engine/js/api/init.md>), 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`](<https://docs.dhtmlx.com/planning-engine/js/api/types/errors.md#inputerror>) | `"InputError"` | The data or options you passed are invalid. | Unchanged. The previous accepted state and its cached result are kept. |
| [`ProjectError`](<https://docs.dhtmlx.com/planning-engine/js/api/types/errors.md#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`](<https://docs.dhtmlx.com/planning-engine/js/api/project-apply.md>) 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](<https://docs.dhtmlx.com/planning-engine/js/error-handling.md#other-throws>) covers them.

## Catching errors

Tell the two library errors apart with `instanceof`, then read `kind` and `path` on an [`InputError`](<https://docs.dhtmlx.com/planning-engine/js/api/types/errors.md#inputerror>) or `code` on a [`ProjectError`](<https://docs.dhtmlx.com/planning-engine/js/api/types/errors.md#projecterror>). Rethrow anything else, so that bugs in your own code don't get reported as bad input:

```js
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:

```js
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`](<https://docs.dhtmlx.com/planning-engine/js/api/types/errors.md#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)`](<https://docs.dhtmlx.com/planning-engine/js/api/project-solve.md>) or [`schedule`](<https://docs.dhtmlx.com/planning-engine/js/api/schedule.md>) 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`](<https://docs.dhtmlx.com/planning-engine/js/api/types/project-data.md>), [`TaskData`](<https://docs.dhtmlx.com/planning-engine/js/api/types/task-data.md>) and [`AssignmentData`](<https://docs.dhtmlx.com/planning-engine/js/api/types/assignment-data.md>).

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:

```js
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`](<https://docs.dhtmlx.com/planning-engine/js/api/project.md>), a [`solve(data)`](<https://docs.dhtmlx.com/planning-engine/js/api/project-solve.md>) that fails validation installs nothing. The project keeps its accepted baseline, and `solve(null)` still returns the result you had before:

```js
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](<https://docs.dhtmlx.com/planning-engine/js/api/project.md#projectoptions>) validates its options too. An unknown `dates` value fails with `path: "dates"`; an unknown `validation` mode or an unknown option key also throws:

```js
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`](<https://docs.dhtmlx.com/planning-engine/js/api/types/errors.md#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`](<https://docs.dhtmlx.com/planning-engine/js/api/project-preview.md>), [`loadProfile`](<https://docs.dhtmlx.com/planning-engine/js/api/project-load-profile.md>), [`evaluate`](<https://docs.dhtmlx.com/planning-engine/js/api/project-evaluate.md>), [`slots`](<https://docs.dhtmlx.com/planning-engine/js/api/project-slots.md>) or [`suggestAssignments`](<https://docs.dhtmlx.com/planning-engine/js/api/project-suggest-assignments.md>) 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`](<https://docs.dhtmlx.com/planning-engine/js/api/types/scheduling-changes.md>) 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)`](<https://docs.dhtmlx.com/planning-engine/js/api/project-solve.md>). Nothing else is accepted until that call succeeds. |

### Telling the codes apart

```js
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`](<https://docs.dhtmlx.com/planning-engine/js/api/project-preview.md>) 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`](<https://docs.dhtmlx.com/planning-engine/js/api/project-apply.md>) 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:

```js
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)`](<https://docs.dhtmlx.com/planning-engine/js/api/project-solve.md>) again. The [recovery how-to](<https://docs.dhtmlx.com/planning-engine/js/how-to/recover-from-rejected-changes.md>) 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`](<https://docs.dhtmlx.com/planning-engine/js/api/types/schedule-result.md>) | Dependency cycles. A cyclic solve returns unchanged dates and empty `updates`, and still installs a baseline. |
| [`ScheduleResult.conflicts`](<https://docs.dhtmlx.com/planning-engine/js/api/types/schedule-result.md>) | `"constraint-violation"` (a link and a hard constraint disagree, and the constraint wins) and `"calendar-non-working"` |
| [`ScheduleResult.notes`](<https://docs.dhtmlx.com/planning-engine/js/api/types/schedule-result.md>) | 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`](<https://docs.dhtmlx.com/planning-engine/js/api/project-apply.md>) | Operations that `apply` refused. The other operations in the change set were still applied. |
| [`CandidateResult.error`](<https://docs.dhtmlx.com/planning-engine/js/api/project-evaluate.md>) | An `evaluate` candidate whose change set was rejected. That candidate has no metrics; the other candidates are scored normally. |
| [`SuggestResult.unfilled` / `invalid`](<https://docs.dhtmlx.com/planning-engine/js/api/project-suggest-assignments.md>) | 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

```js
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" })`](<https://docs.dhtmlx.com/planning-engine/js/api/project.md#projectoptions>) 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](<https://docs.dhtmlx.com/planning-engine/js/api/types/schedule-result.md#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`.

```js
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](<https://docs.dhtmlx.com/planning-engine/js/how-to/accept-messy-input.md>) shows the repair flow end to end.

## Errors from slots

[`slots`](<https://docs.dhtmlx.com/planning-engine/js/api/project-slots.md>) throws the same two classes as every other method:

- [`InputError`](<https://docs.dhtmlx.com/planning-engine/js/api/types/errors.md#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")`](<https://docs.dhtmlx.com/planning-engine/js/api/types/errors.md#projecterror>) when the project has no clean solved baseline. Call [`solve`](<https://docs.dhtmlx.com/planning-engine/js/api/project-solve.md>) before `slots`, including after [`apply`](<https://docs.dhtmlx.com/planning-engine/js/api/project-apply.md>).

So one `catch` covers the whole staffing flow, `slots` followed by [`suggestAssignments`](<https://docs.dhtmlx.com/planning-engine/js/api/project-suggest-assignments.md>):

```js
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](<https://docs.dhtmlx.com/planning-engine/js/api/types/errors.md#other-errors>)):

| Thrown | When | What to do |
|---|---|---|
| `Error("Project has been disposed")` | Any method call on a project after [`dispose()`](<https://docs.dhtmlx.com/planning-engine/js/api/project.md#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. |

```js
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.
