Cerial

NONE vs null

How SurrealDB's three-state field model (value, NONE, null) maps to TypeScript types, query operators, and update behavior.

SurrealDB distinguishes between three field states:

  • Value — the field exists and has a typed value
  • NONE — the field doesn't exist at all (absent from the record)
  • null — the field exists and its value is explicitly null

Cerial models these states using two independent schema modifiers:

  • ? (optional) — enables NONE. Maps to undefined in TypeScript.
  • @nullable — enables null. Maps to | null in TypeScript.

These modifiers are independent. You can use either one, both, or neither. The combination determines what states a field can hold.

The Three-State Model

SchemaTypeScript OutputAllowed StatesMigration
name Stringname: stringvalueTYPE string
bio String?bio?: stringvalue, NONETYPE option<string>
bio String @nullablebio: string | nullvalue, nullTYPE string | null
bio String? @nullablebio?: string | nullvalue, null, NONETYPE option<string | null>

Key observations:

  • ? without @nullable — the field can be absent (NONE) but cannot be null. Passing null is a validation error.
  • @nullable without ? — the field must always be present but can be null. The field is required on create (you must pass a value or null).
  • Both ? and @nullable — the field can be any of: a typed value, null, or absent (NONE).

Runtime Behavior

Optional-Only (String?)

// Schema: bio String?

await db.User.create({ data: { name: 'Alice' } });
// bio is NONE (field absent in DB)

await db.User.create({ data: { name: 'Bob', bio: 'Hello' } });
// bio = 'Hello'

await db.User.create({ data: { name: 'Carol', bio: null } });
// Error: bio is not nullable — use undefined/omit to unset

The field either has a string value or doesn't exist. There's no middle ground.

Nullable-Only (String @nullable)

// Schema: deletedAt Date @nullable

await db.User.create({ data: { name: 'Alice', deletedAt: null } });
// deletedAt = null (stored as null)

await db.User.create({ data: { name: 'Bob', deletedAt: new Date() } });
// deletedAt = current date

await db.User.create({ data: { name: 'Carol' } });
// Error: deletedAt is required (must be a Date or null)

The field is always present. It holds either a typed value or null.

Optional + Nullable (String? @nullable)

// Schema: bio String? @nullable

await db.User.create({ data: { name: 'Alice' } });
// bio is NONE (field absent)

await db.User.create({ data: { name: 'Bob', bio: null } });
// bio = null (explicit null stored)

await db.User.create({ data: { name: 'Carol', bio: 'Hello' } });
// bio = 'Hello'

All three states are available. This gives maximum flexibility when you need to distinguish "never set" from "explicitly cleared."

Optional Record Fields

// Schema: authorId Record? @nullable

await db.Post.create({ data: { title: 'Draft' } });
// authorId is NONE (field absent — no FK stored)

await db.Post.create({ data: { title: 'Orphan', authorId: null } });
// authorId = null (explicit null — "unassigned")

await db.Post.create({ data: { title: 'Post', authorId: userId } });
// authorId = record reference to user

Without @nullable, Record? only supports value or NONE. Passing null is rejected.

Query Operators

Cerial provides specific operators based on the field modifiers. These operators are only available on fields that support the corresponding state.

OperatorAvailable OnDescription
isNull: true@nullable fieldsField is null
isNull: false@nullable fieldsField is not null
isNone: true? (optional) fieldsField is absent
isNone: false? (optional) fieldsField is present
isDefined: true? (optional) fieldsAlias for isNone: false
isDefined: false? (optional) fieldsAlias for isNone: true

See the special operators page for more details.

Finding null Values

// Only on @nullable fields
await db.User.findMany({
  where: { deletedAt: { isNull: true } },
});

Finding Absent Fields (NONE)

// Only on optional (?) fields
await db.User.findMany({
  where: { bio: { isNone: true } },
});

Combining on Optional + Nullable Fields

// Schema: bio String? @nullable — has all three operators

// Find users where bio doesn't exist at all
await db.User.findMany({ where: { bio: { isNone: true } } });

// Find users where bio is explicitly null
await db.User.findMany({ where: { bio: { isNull: true } } });

// Find users where bio has an actual value (not null, not NONE)
await db.User.findMany({
  where: { bio: { isNone: false, isNull: false } },
});

Update Behavior

Setting a Field to null

Only works on @nullable fields. Sets the field value to null in the database:

// Schema: bio String? @nullable
await db.User.updateMany({
  where: { id: userId },
  data: { bio: null },
});

Removing a Field (NONE)

Only works on optional (?) fields. You can either use the NONE sentinel in data or the unset parameter:

import { NONE } from 'cerial';

// Option 1: NONE sentinel in data
await db.User.updateMany({
  where: { id: userId },
  data: { bio: NONE },
});

// Option 2: unset parameter (cleaner for multiple fields)
await db.User.updateMany({
  where: { id: userId },
  data: {},
  unset: { bio: true },
});

The unset parameter also supports nested object fields and tuple elements. See the updateMany, updateUnique, and upsert docs for details.

Disconnecting Optional Relations

How disconnect works depends on @nullable:

// Record? (no @nullable) — disconnect sets FK to NONE
await db.Post.updateUnique({
  where: { id: postId },
  data: { author: { disconnect: true } },
});
// FK becomes absent (NONE)

// Record? @nullable — disconnect sets FK to NULL
await db.Post.updateUnique({
  where: { id: postId },
  data: { author: { disconnect: true } },
});
// FK becomes null

The distinction matters for queries: after a NONE disconnect, isNone: true matches. After a NULL disconnect, isNull: true matches.

The @default(null) Pattern

@default(null) requires @nullable on the field. It converts omitted values to null on create:

model User {
  id Record @id
  bio String? @nullable @default(null)
}
// Without @default(null): omitting = NONE
// With @default(null): omitting = null (default applied)
await db.User.create({ data: { name: 'Alice' } });
// bio = null (not NONE)

This is useful when you want omitted fields to be queryable with isNull: true instead of isNone: true.

Practical Guidelines

  1. Use ? alone when the field is truly optional. It either has a value or doesn't exist. Most optional fields fall here.

  2. Use @nullable alone for soft-delete patterns. deletedAt Date @nullable is required but can be null (not deleted) or a Date (deleted at timestamp).

  3. Use ? @nullable for FK fields that need null queries. authorId Record? @nullable lets you query { isNull: true } to find unassigned records, and { isNone: true } to find records where the field was never set.

  4. Use @default(null) with @nullable to ensure optional fields are always queryable by null instead of being absent.

On this page