# Implementing Custom Node Types in Pascal Editor Using Zod Schemas

> Learn to implement custom node types in Pascal Editor by extending Zod schemas. Register and create your nodes easily for enhanced editor functionality.

- Repository: [Pascal/editor](https://github.com/pascalorg/editor)
- Tags: how-to-guide
- Published: 2026-03-25

---

**To implement a custom node type in Pascal Editor, extend the `BaseNode` Zod schema with your specific fields, export it from [`packages/core/src/schema/index.ts`](https://github.com/pascalorg/editor/blob/main/packages/core/src/schema/index.ts) to register it with the `AnyNode` union, and instantiate it using `useScene.getState().createNode()` after validating with `.parse()`.**

Pascal Editor stores every building element as a Zod-validated node within the core `@pascal-app/core` package. Implementing custom node types in Pascal Editor using Zod schemas ensures full type safety, runtime validation, and seamless integration with the editor's undo/redo systems powered by Zundo. This guide walks through the exact implementation pattern used in the `pascalorg/editor` repository, referencing the discriminated union architecture and specific file paths that define the scene graph.

## Step 1: Define the Zod Schema by Extending BaseNode

Create a new schema file in `packages/core/src/schema/nodes/` to define your custom node's shape and validation rules. The following example implements a `FurnitureNode` that follows the same pattern as the built-in `WallNode`:

```typescript
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { ItemNode } from './item'

export const FurnitureNode = BaseNode.extend({
  // Unique identifier with the "furn" prefix
  id: objectId('furn'),
  // Discriminated union key
  type: nodeType('furn'),

  // Position & orientation relative to the level
  position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
  rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
  scale:    z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]),

  // Custom fields for furniture
  name:   z.string(),
  material: z.enum(['wood', 'metal', 'plastic']).optional(),
  // Optional link to an existing ItemNode asset
  asset: ItemNode.shape.asset,
}).describe(
  dedent`
    Furniture node – a custom piece of furniture that can be placed on a floor.
    - position / rotation / scale follow the same convention as ItemNode.
    - material is an optional enum for quick styling.
  `
)

export type FurnitureNode = z.infer<typeof FurnitureNode>

```

**Key implementation details:**

- **`BaseNode`** supplies common fields including `object`, `id`, `type`, `name`, `parentId`, `visible`, and `metadata`.
- **`objectId('furn')`** automatically generates typed identifiers like `furn_4k7g9b1c`.
- **`.describe()`** attaches markdown documentation that appears in generated docs and IDE tooltips.

## Step 2: Export the Schema to Register with AnyNode

Add the newly created export to the central schema index at [`packages/core/src/schema/index.ts`](https://github.com/pascalorg/editor/blob/main/packages/core/src/schema/index.ts) so the rest of the codebase can import the type:

```typescript
// packages/core/src/schema/index.ts
export { WallNode } from './nodes/wall'
export { FurnitureNode } from './nodes/furniture'   // ← new line

```

This export automatically includes `FurnitureNode` in the **discriminated union** defined in [`packages/core/src/schema/types.ts`](https://github.com/pascalorg/editor/blob/main/packages/core/src/schema/types.ts):

```typescript
export const AnyNode = z.discriminatedUnion('type', [
  SiteNode,
  BuildingNode,
  LevelNode,
  WallNode,
  ItemNode,
  // …
  FurnitureNode,   // ← added automatically via the index import
])

```

The union uses the `type` field as the discriminator, enabling TypeScript to narrow node types correctly throughout the editor.

## Step 3: Create Runtime Instances Using useScene

Instantiate your custom node in any component or tool by parsing the schema and calling the store method. The `useScene` hook (implemented in [`packages/core/src/store/use-scene.ts`](https://github.com/pascalorg/editor/blob/main/packages/core/src/store/use-scene.ts)) manages the flat `SceneState.nodes` dictionary and provides the `createNode` function:

```tsx
import { useScene } from '@pascal-app/core'
import { FurnitureNode } from '@pascal-app/core/schema'

function addSampleChair() {
  const { createNode, getState } = useScene.getState()

  // Parse the schema – ensures validation & TypeScript inference
  const chair = FurnitureNode.parse({
    name: 'Dining Chair',
    material: 'wood',
    position: [2, 0, 3],
    asset: {
      id: 'chair01',
      category: 'furniture',
      name: 'Wooden Chair',
      thumbnail: '/thumbs/chair.png',
      src: '/models/chair.glb',
      dimensions: [0.5, 0.9, 0.5],
      attachTo: 'floor',
    },
  })

  // Attach to the current level (or any parent node)
  const currentLevelId = getState().rootNodeIds[0]
  createNode(chair, currentLevelId)
}

```

Because `FurnitureNode` is a member of `AnyNode`, the Zundo-based store accepts it without additional type declarations. Systems iterating over `scene.nodes` will ignore the new type unless you add specific logic, which prevents breaking existing functionality.

## Step 4: Extend Systems and Renderers (Optional)

If your custom node requires specialized rendering or behavior, extend the viewer and core systems while maintaining the decoupled architecture:

- **Renderer**: Add a component in `packages/viewer/src/components/renderers/` that checks `node.type === 'furn'` and loads the GLB model from `node.asset.src` using Three.js.
- **System**: Add logic in `packages/core/src/systems/` to handle physics, spatial queries, or custom interactions for the new node type.

The `apps/editor` package never imports viewer internals, ensuring your custom node remains portable across different frontends using the Core API.

## Key Source Files for Custom Node Implementation

- **[`packages/core/src/schema/base.ts`](https://github.com/pascalorg/editor/blob/main/packages/core/src/schema/base.ts)** – Defines `BaseNode`, `objectId`, and `nodeType` utilities.
- **[`packages/core/src/schema/nodes/wall.ts`](https://github.com/pascalorg/editor/blob/main/packages/core/src/schema/nodes/wall.ts)** – Canonical reference implementation showing the extension pattern.
- **[`packages/core/src/schema/index.ts`](https://github.com/pascalorg/editor/blob/main/packages/core/src/schema/index.ts)** – Central export hub; required for registering new nodes.
- **[`packages/core/src/schema/types.ts`](https://github.com/pascalorg/editor/blob/main/packages/core/src/schema/types.ts)** – Builds the `AnyNode` discriminated union that powers type-safe stores.
- **[`packages/core/src/store/use-scene.ts`](https://github.com/pascalorg/editor/blob/main/packages/core/src/store/use-scene.ts)** – Provides `createNode`, `createNodes`, and Zundo-powered state management.

## Summary

- **Define** your schema by extending `BaseNode` in `packages/core/src/schema/nodes/` to establish custom fields, validation, and defaults.
- **Export** the schema from [`packages/core/src/schema/index.ts`](https://github.com/pascalorg/editor/blob/main/packages/core/src/schema/index.ts) to automatically include it in the `AnyNode` discriminated union.
- **Instantiate** nodes by calling `.parse()` for runtime validation, then pass the result to `useScene.getState().createNode(node, parentId)`.
- **Optional**: Add specialized renderers in `packages/viewer/` or systems in `packages/core/src/systems/` for custom visual or logical behavior.

## Frequently Asked Questions

### What is the purpose of the AnyNode discriminated union in Pascal Editor?

The `AnyNode` type, defined in [`packages/core/src/schema/types.ts`](https://github.com/pascalorg/editor/blob/main/packages/core/src/schema/types.ts), creates a TypeScript discriminated union keyed on the `type` field. This allows the `useScene` store and other systems to handle all node types exhaustively while maintaining strict type safety, ensuring that only valid node shapes are accepted by generic scene operations.

### How does Zod validation impact runtime behavior when creating custom nodes?

Zod schemas enforce runtime validation through the `.parse()` method. When you call `FurnitureNode.parse()`, Zod validates that the object conforms to the schema, strips unknown fields, applies defaults (such as the `[0,0,0]` position defaults), and returns a type-safe object that matches the TypeScript `z.infer<typeof FurnitureNode>` type.

### Can custom nodes reference existing node assets like ItemNode?

Yes, custom nodes can reuse existing asset structures by importing and referencing other node schemas. In the `FurnitureNode` example, the schema imports `ItemNode` and references `ItemNode.shape.asset` to maintain consistency with existing asset definitions, ensuring that furniture loads GLB models using the same validation logic as standard items.

### Where should custom renderers for new node types be implemented?

Custom renderers belong in `packages/viewer/src/components/renderers/` and should check `node.type` to determine rendering responsibilities. The viewer package is deliberately decoupled from the editor application, so you must expose the new node through the public `@pascal-app/core` API, then handle the Three.js mesh creation and GLB loading within the viewer's renderer components.