Skip to content

Attribute Casting

D1 stores only four primitive types: TEXT, INTEGER, REAL, and BLOB. Attribute casting lets you declare how columns map to JavaScript types — d1-eloquent handles the conversion automatically in both directions.

ts
// Reading — manual conversion scattered across your codebase
const row = results[0]
const isPublished = row.is_published === 1        // remember: D1 returns 0/1
const metadata = JSON.parse(row.metadata as string) // hope it's valid JSON
const createdAt = new Date(row.created_at as string) // don't forget this one

// Writing — mirror every conversion in reverse
await env.DB.prepare(
  'INSERT INTO posts (id, is_published, metadata, created_at) VALUES (?, ?, ?, ?)'
).bind(
  id,
  post.isPublished ? 1 : 0,             // boolean → integer
  JSON.stringify(post.metadata),          // object → JSON string
  post.createdAt.toISOString()            // Date → ISO string
).run()

// Every read site and every write site must agree on the conversion.
// Forget one? Silent data corruption.
ts
class Post extends BaseModel<PostAttrs> {
  static table = 'posts'
  static casts = {
    is_published: 'boolean',   // D1 INTEGER 0/1 ↔ JS boolean
    metadata: 'json',          // D1 TEXT ↔ JS object
  }
}

// Reading — automatic, everywhere
post.get('is_published')   // true (boolean, not 1)
post.get('metadata')       // { key: "val" } (object, not string)
post.get('created_at')     // Date instance (auto-cast for timestamps)

// Writing — automatic, everywhere
post.set('is_published', false)
post.set('metadata', { key: 'new' })
await post.save()          // d1-eloquent dehydrates to DB-safe values

Declaring Casts

Add a static casts object to your model. Keys are column names, values are cast type strings:

ts
import { BaseModel } from '@orphnet/d1-eloquent'

interface PostAttrs {
  id: string
  title: string
  is_published: boolean
  metadata: Record<string, unknown>
  view_count: number
  created_at: Date
  updated_at: Date
}

class Post extends BaseModel<PostAttrs> {
  static table = 'posts'
  static casts = {
    is_published: 'boolean',
    metadata: 'json',
    view_count: 'integer',
  }
}

After casting, post.get('is_published') returns a boolean (not 0/1), and post.get('metadata') returns an object (not a JSON string).

Built-in Cast Types

CastD1 typeJS typeGet exampleSet example
'boolean'INTEGER (0/1)boolean1truetrue1
'integer'INTEGERnumber"42"423.73
'float' / 'real'REALnumber"3.14"3.14passthrough
'string'TEXTstring123"123"passthrough
'datetime'TEXT (ISO 8601)Date"2026-01-15T..."DateDate → ISO string
'date'TEXT (YYYY-MM-DD)Date"2026-01-15"Date (midnight UTC)Date"2026-01-15"
'timestamp'INTEGER (unix)Date1700000000DateDate1700000000
'json'TEXTunknown'{"a":1}'{a: 1}{a: 1}'{"a":1}'
'array'TEXTunknown[]'["a","b"]'["a","b"]["a","b"]'["a","b"]'
'blob'BLOBArrayBufferpassthroughpassthrough

The 'blob' cast is a passthrough for binary columns: D1 returns BLOB values as ArrayBuffer, and set accepts an ArrayBuffer or typed array unchanged. It exists so binary columns are tracked in the attribute manager and can be typed explicitly via casts: { col: 'blob' }.

Enum Casts

SQLite has no native enum type, so enumCast(values) provides a runtime-validated cast: the value is checked against the allowed set on both read (DB → app) and write (app → DB, and in cast-aware where clauses). Pair it with a TS union on your attrs interface for compile-time safety too. null / undefined pass through (use a NOT NULL column to forbid them).

ts
import { BaseModel, enumCast } from '@orphnet/d1-eloquent'

type Status = 'draft' | 'published' | 'archived'
interface PostAttrs { id: string; status: Status; priority: 1 | 2 | 3 | null }

class Post extends BaseModel<PostAttrs> {
  static table = 'posts'
  static casts = {
    status: enumCast(['draft', 'published', 'archived']),
    priority: enumCast([1, 2, 3]),   // numeric enums work too
  } as const
}

await Post.create(db, { status: 'published', priority: 2 })   // ok
await Post.create(db, { status: 'spam' })                     // throws: Invalid enum value "spam"
Post.query().whereEq('status', 'published')                   // validated + filtered

By default an out-of-set value throws on read as well as write. The optional second argument relaxes reads only: enumCast(values, opts?: { onInvalidRead?: 'throw' | 'null' | 'keep' }). 'null' returns null for an invalid stored value, 'keep' returns it unchanged, and 'throw' (the default) raises. Writes always throw regardless of the option - only reads degrade. Useful when legacy rows hold values no longer in the set, so a single bad row doesn't fail a whole list query.

ts
static casts = {
  // legacy rows may hold retired statuses - read them as null instead of throwing
  status: enumCast(['draft', 'published', 'archived'], { onInvalidRead: 'null' }),
} as const

Validation fires on persist (create/save), on hydration (reads), and when an enum column is used as a filter value — not on a bare set() (which is validated when you save()).

Automatic Timestamp Casting

When timestamps = true (the default), created_at and updated_at are automatically cast to datetime. When softDeletes = true, deleted_at is also auto-cast.

You do not need to declare these in static casts — they are registered automatically. If you do declare them, your cast takes priority.

ts
class User extends BaseModel<UserAttrs> {
  static table = 'users'
  static timestamps = true   // created_at/updated_at auto-cast to Date
  static softDeletes = true  // deleted_at auto-cast to Date
}

const user = await User.find(userId)
user.get('created_at')  // Date instance, not a string

Custom Casts

For types not covered by the built-in casts, implement the AttributeCast interface:

ts
import { BaseModel } from '@orphnet/d1-eloquent'
import type { AttributeCast } from '@orphnet/d1-eloquent'

const pointCast: AttributeCast<{ x: number; y: number }, { x: number; y: number }> = {
  get: (value) => JSON.parse(value as string),
  set: (value) => JSON.stringify(value),
}

class Marker extends BaseModel<MarkerAttrs> {
  static table = 'markers'
  static casts = {
    position: pointCast,
  }
}

The get method transforms DB values → application values. The set method transforms application values → DB values.

Null Handling

null and undefined values pass through casts unchanged — no cast function is called. This matches SQL NULL semantics.

ts
const model = new Post({ is_published: null })
model.get('is_published')  // null (not false)

toObject() vs toRaw()

MethodReturnsUse case
toObject()Cast (application) valuesAPI responses, UI rendering
toRaw()Dehydrated (DB-safe) valuesManual SQL, debugging, serialization
ts
const post = await Post.find(postId)

post.toObject()
// { id: "...", is_published: true, metadata: { key: "val" }, created_at: Date, ... }

post.toRaw()
// { id: "...", is_published: 1, metadata: '{"key":"val"}', created_at: "2026-01-15T...", ... }

Revisions

Revision snapshots (before, after, diff) store raw (dehydrated) values. This ensures revisions contain DB-safe primitives that can be replayed reliably.

Released under the MIT License.