Remove Zod from named errors (#26982)

This commit is contained in:
Kit Langton
2026-05-12 09:20:15 -04:00
committed by GitHub
parent 8feb4a31c7
commit 28f38fc871
20 changed files with 197 additions and 245 deletions

View File

@@ -1,8 +1,8 @@
import z from "zod"
import { Schema } from "effect"
export abstract class NamedError extends Error {
abstract schema(): z.core.$ZodType
abstract toObject(): { name: string; data: any }
abstract schema(): Schema.Top
abstract toObject(): { name: string; data: unknown }
static hasName(error: unknown, name: string): boolean {
return (
@@ -10,30 +10,42 @@ export abstract class NamedError extends Error {
)
}
static create<Name extends string, Data extends z.core.$ZodType>(name: Name, data: Data) {
const schema = z
.object({
name: z.literal(name),
data,
})
.meta({
ref: name,
})
static create<Name extends string, Fields extends Schema.Struct.Fields>(
name: Name,
fields: Fields,
): ReturnType<typeof NamedError.createSchemaClass<Name, Schema.Struct<Fields>>>
static create<Name extends string, DataSchema extends Schema.Top>(
name: Name,
data: DataSchema,
): ReturnType<typeof NamedError.createSchemaClass<Name, DataSchema>>
static create<Name extends string>(name: Name, data: Schema.Top | Schema.Struct.Fields) {
return NamedError.createSchemaClass(name, Schema.isSchema(data) ? data : Schema.Struct(data))
}
private static createSchemaClass<Name extends string, DataSchema extends Schema.Top>(name: Name, data: DataSchema) {
const schema = Schema.Struct({
name: Schema.Literal(name),
data,
}).annotate({ identifier: name })
type Data = Schema.Schema.Type<DataSchema>
const result = class extends NamedError {
public static readonly Schema = schema
public static readonly EffectSchema = schema
public static readonly tag = name
public override readonly name = name as Name
public override readonly name = name
constructor(
public readonly data: z.input<Data>,
public readonly data: Data,
options?: ErrorOptions,
) {
super(name, options)
this.name = name
}
static isInstance(input: any): input is InstanceType<typeof result> {
return typeof input === "object" && "name" in input && input.name === name
static isInstance(input: unknown): input is InstanceType<typeof result> {
return NamedError.hasName(input, name)
}
schema() {
@@ -51,10 +63,7 @@ export abstract class NamedError extends Error {
return result
}
public static readonly Unknown = NamedError.create(
"UnknownError",
z.object({
message: z.string(),
}),
)
public static readonly Unknown = NamedError.create("UnknownError", {
message: Schema.String,
})
}

View File

@@ -15,7 +15,6 @@ import { Binary } from "@opencode-ai/core/util/binary"
import { NamedError } from "@opencode-ai/core/util/error"
import { DateTime } from "luxon"
import { createStore } from "solid-js/store"
import z from "zod"
import NotFound from "../[...404]"
import { Tabs } from "@opencode-ai/ui/tabs"
import { MessageNav } from "@opencode-ai/ui/message-nav"
@@ -33,13 +32,28 @@ const ClientOnlyWorkerPoolProvider = clientOnly(() =>
})),
)
const SessionDataMissingError = NamedError.create(
"SessionDataMissingError",
z.object({
sessionID: z.string(),
message: z.string().optional(),
}),
)
class SessionDataMissingError extends NamedError {
public override readonly name = "SessionDataMissingError"
constructor(
public readonly data: { sessionID: string; message?: string },
options?: ErrorOptions,
) {
super("SessionDataMissingError", options)
}
static isInstance(input: unknown): input is SessionDataMissingError {
return NamedError.hasName(input, "SessionDataMissingError")
}
schema(): never {
throw new Error("SessionDataMissingError does not expose a schema")
}
toObject() {
return { name: this.name, data: this.data }
}
}
const getData = query(async (shareID) => {
"use server"

View File

@@ -1,6 +1,6 @@
import z from "zod"
import { EOL } from "os"
import { NamedError } from "@opencode-ai/core/util/error"
import { Schema } from "effect"
import { logo as glyphs } from "./logo"
const wordmark = [
@@ -10,7 +10,7 @@ const wordmark = [
`▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`,
]
export const CancelledError = NamedError.create("UICancelledError", z.void())
export const CancelledError = NamedError.create("UICancelledError", Schema.optional(Schema.Void))
export const Style = {
TEXT_HIGHLIGHT: "\x1b[96m",

View File

@@ -2,7 +2,6 @@ import * as Log from "@opencode-ai/core/util/log"
import path from "path"
import { pathToFileURL } from "url"
import os from "os"
import z from "zod"
import { mergeDeep } from "remeda"
import { Global } from "@opencode-ai/core/global"
import fsNode from "fs/promises"
@@ -357,14 +356,11 @@ function writableGlobal(info: Info) {
return next
}
export const ConfigDirectoryTypoError = NamedError.create(
"ConfigDirectoryTypoError",
z.object({
path: z.string(),
dir: z.string(),
suggestion: z.string(),
}),
)
export const ConfigDirectoryTypoError = NamedError.create("ConfigDirectoryTypoError", {
path: Schema.String,
dir: Schema.String,
suggestion: Schema.String,
})
export const layer = Layer.effect(
Service,

View File

@@ -1,21 +1,23 @@
export * as ConfigError from "./error"
import z from "zod"
import { NamedError } from "@opencode-ai/core/util/error"
import { Schema } from "effect"
export const JsonError = NamedError.create(
"ConfigJsonError",
z.object({
path: z.string(),
message: z.string().optional(),
const Issue = Schema.StructWithRest(
Schema.Struct({
message: Schema.String,
path: Schema.Array(Schema.String),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
export const InvalidError = NamedError.create(
"ConfigInvalidError",
z.object({
path: z.string(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
message: z.string().optional(),
}),
)
export const JsonError = NamedError.create("ConfigJsonError", {
path: Schema.String,
message: Schema.optional(Schema.String),
})
export const InvalidError = NamedError.create("ConfigInvalidError", {
path: Schema.String,
issues: Schema.optional(Schema.Array(Issue)),
message: Schema.optional(Schema.String),
})

View File

@@ -1,6 +1,6 @@
import { NamedError } from "@opencode-ai/core/util/error"
import matter from "gray-matter"
import { z } from "zod"
import { Schema } from "effect"
import { Filesystem } from "@/util/filesystem"
export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
@@ -88,12 +88,9 @@ export async function parse(filePath: string) {
}
}
export const FrontmatterError = NamedError.create(
"ConfigFrontmatterError",
z.object({
path: z.string(),
message: z.string(),
}),
)
export const FrontmatterError = NamedError.create("ConfigFrontmatterError", {
path: Schema.String,
message: Schema.String,
})
export * as ConfigMarkdown from "./markdown"

View File

@@ -2,7 +2,6 @@ export * as ConfigParse from "./parse"
import { type ParseError as JsoncParseError, parse as parseJsoncImpl, printParseErrorCode } from "jsonc-parser"
import { Cause, Exit, Schema as EffectSchema, SchemaIssue } from "effect"
import type z from "zod"
import type { DeepMutable } from "@opencode-ai/core/schema"
import { InvalidError, JsonError } from "./error"
@@ -48,7 +47,7 @@ export function schema<S extends EffectSchema.Decoder<unknown, never>>(
keys: extra,
path: [],
message: `Unrecognized key${extra.length === 1 ? "" : "s"}: ${extra.join(", ")}`,
} as z.core.$ZodIssue,
},
],
})
}
@@ -61,8 +60,12 @@ export function schema<S extends EffectSchema.Decoder<unknown, never>>(
{
path: source,
issues: EffectSchema.isSchemaError(error)
? (SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues as z.core.$ZodIssue[])
: ([{ code: "custom", message: String(error), path: [] }] as z.core.$ZodIssue[]),
? SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues.map((issue) => ({
...issue,
message: issue.message,
path: issue.path?.map(String) ?? [],
}))
: [{ message: String(error), path: [] }],
},
{ cause: error },
)

View File

@@ -1,5 +1,4 @@
import { BusEvent } from "@/bus/bus-event"
import z from "zod"
import { Schema } from "effect"
import { NamedError } from "@opencode-ai/core/util/error"
import * as Log from "@opencode-ai/core/util/log"
@@ -24,14 +23,11 @@ export const Event = {
),
}
export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", z.object({}))
export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", {})
export const InstallFailedError = NamedError.create(
"InstallFailedError",
z.object({
stderr: z.string(),
}),
)
export const InstallFailedError = NamedError.create("InstallFailedError", {
stderr: Schema.String,
})
export function ide() {
if (process.env["TERM_PROGRAM"] === "vscode") {

View File

@@ -39,6 +39,7 @@ import { PluginCommand } from "./cli/cmd/plug"
import { Heap } from "./cli/heap"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process"
import { isRecord } from "@/util/record"
const processMetadata = ensureProcessMetadata("main")
@@ -203,13 +204,6 @@ try {
}
} catch (e) {
let data: Record<string, any> = {}
if (e instanceof NamedError) {
const obj = e.toObject()
Object.assign(data, {
...obj.data,
})
}
if (e instanceof Error) {
Object.assign(data, {
name: e.name,
@@ -219,6 +213,16 @@ try {
})
}
if (e instanceof NamedError) {
const obj = e.toObject()
if (isRecord(obj.data)) {
for (const [key, value] of Object.entries(obj.data)) {
if (key === "name" || key === "stack" || key === "cause") continue
data[key] = value
}
}
}
if (e instanceof ResolveMessage) {
Object.assign(data, {
name: e.name,

View File

@@ -7,7 +7,6 @@ import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types
import * as Log from "@opencode-ai/core/util/log"
import { Process } from "@/util/process"
import { LANGUAGE_EXTENSIONS } from "./language"
import z from "zod"
import { Schema } from "effect"
import type * as LSPServer from "./server"
import { NamedError } from "@opencode-ai/core/util/error"
@@ -32,12 +31,9 @@ export type Info = NonNullable<Awaited<ReturnType<typeof create>>>
export type Diagnostic = VSCodeDiagnostic
export const InitializeError = NamedError.create(
"LSPInitializeError",
z.object({
serverID: z.string(),
}),
)
export const InitializeError = NamedError.create("LSPInitializeError", {
serverID: Schema.String,
})
export const Event = {
Diagnostics: BusEvent.define(

View File

@@ -68,12 +68,9 @@ export const BrowserOpenFailed = BusEvent.define(
}),
)
export const Failed = NamedError.create(
"MCPFailed",
z.object({
name: z.string(),
}),
)
export const Failed = NamedError.create("MCPFailed", {
name: Schema.String,
})
type MCPClient = Client

View File

@@ -382,9 +382,7 @@ export type Part =
const AssistantErrorSchema = Schema.Union([
AuthError.EffectSchema,
Schema.Struct({ name: Schema.Literal("UnknownError"), data: Schema.Struct({ message: Schema.String }) }).annotate({
identifier: "UnknownError",
}),
NamedError.Unknown.EffectSchema,
OutputLengthError.EffectSchema,
AbortedError.EffectSchema,
StructuredOutputError.EffectSchema,

View File

@@ -3,6 +3,7 @@ import { SessionID } from "./schema"
import { ModelID, ProviderID } from "../provider/schema"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { namedSchemaError } from "@/util/named-schema-error"
import { NamedError } from "@opencode-ai/core/util/error"
export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {})
export const AuthError = namedSchemaError("ProviderAuthError", {
@@ -10,26 +11,6 @@ export const AuthError = namedSchemaError("ProviderAuthError", {
message: Schema.String,
})
const AuthErrorEffect = Schema.Struct({
name: Schema.Literal("ProviderAuthError"),
data: Schema.Struct({
providerID: Schema.String,
message: Schema.String,
}),
})
const OutputLengthErrorEffect = Schema.Struct({
name: Schema.Literal("MessageOutputLengthError"),
data: Schema.Struct({}),
})
const UnknownErrorEffect = Schema.Struct({
name: Schema.Literal("UnknownError"),
data: Schema.Struct({
message: Schema.String,
}),
})
export const ToolCall = Schema.Struct({
state: Schema.Literal("call"),
step: Schema.optional(NonNegativeInt),
@@ -124,7 +105,9 @@ export const Info = Schema.Struct({
created: NonNegativeInt,
completed: Schema.optional(NonNegativeInt),
}),
error: Schema.optional(Schema.Union([AuthErrorEffect, UnknownErrorEffect, OutputLengthErrorEffect])),
error: Schema.optional(
Schema.Union([AuthError.EffectSchema, NamedError.Unknown.EffectSchema, OutputLengthError.EffectSchema]),
),
sessionID: SessionID,
tool: Schema.Record(
Schema.String,

View File

@@ -2,6 +2,7 @@ import type { NamedError } from "@opencode-ai/core/util/error"
import { Cause, Clock, Duration, Effect, Schedule } from "effect"
import { MessageV2 } from "./message-v2"
import { iife } from "@/util/iife"
import { isRecord } from "@/util/record"
export type Err = ReturnType<NamedError["toObject"]>
@@ -121,7 +122,7 @@ export function retryable(error: Err, provider: string) {
}
// Check for rate limit patterns in plain text error messages
const msg = error.data?.message
const msg = isRecord(error.data) ? error.data.message : undefined
if (typeof msg === "string") {
const lower = msg.toLowerCase()
if (
@@ -133,7 +134,7 @@ export function retryable(error: Err, provider: string) {
}
}
const json = parseJSON(error.data?.message)
const json = parseJSON(msg)
if (!json || typeof json !== "object") return undefined
const code = typeof json.code === "string" ? json.code : ""

View File

@@ -1,6 +1,5 @@
import path from "path"
import { pathToFileURL } from "url"
import z from "zod"
import { Effect, Layer, Context, Schema } from "effect"
import { NamedError } from "@opencode-ai/core/util/error"
import type { Agent } from "@/agent/agent"
@@ -16,6 +15,7 @@ import { Glob } from "@opencode-ai/core/util/glob"
import * as Log from "@opencode-ai/core/util/log"
import { Discovery } from "./discovery"
import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" }
import { isRecord } from "@/util/record"
const log = Log.create({ service: "skill" })
const CLAUDE_EXTERNAL_DIR = ".claude"
@@ -41,23 +41,33 @@ export const Info = Schema.Struct({
})
export type Info = Schema.Schema.Type<typeof Info>
export const InvalidError = NamedError.create(
"SkillInvalidError",
z.object({
path: z.string(),
message: z.string().optional(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
const Issue = Schema.StructWithRest(
Schema.Struct({
message: Schema.String,
path: Schema.Array(Schema.String),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
export const NameMismatchError = NamedError.create(
"SkillNameMismatchError",
z.object({
path: z.string(),
expected: z.string(),
actual: z.string(),
}),
)
function isSkillFrontmatter(data: unknown): data is { name: string; description?: string } {
return (
isRecord(data) &&
typeof data.name === "string" &&
(data.description === undefined || typeof data.description === "string")
)
}
export const InvalidError = NamedError.create("SkillInvalidError", {
path: Schema.String,
message: Schema.optional(Schema.String),
issues: Schema.optional(Schema.Array(Issue)),
})
export const NameMismatchError = NamedError.create("SkillNameMismatchError", {
path: Schema.String,
expected: Schema.String,
actual: Schema.String,
})
type State = {
skills: Record<string, Info>
@@ -101,21 +111,20 @@ const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.I
if (!md) return
const parsed = z.object({ name: z.string(), description: z.string().optional() }).safeParse(md.data)
if (!parsed.success) return
if (!isSkillFrontmatter(md.data)) return
if (state.skills[parsed.data.name]) {
if (state.skills[md.data.name]) {
log.warn("duplicate skill name", {
name: parsed.data.name,
existing: state.skills[parsed.data.name].location,
name: md.data.name,
existing: state.skills[md.data.name].location,
duplicate: match,
})
}
state.dirs.add(path.dirname(match))
state.skills[parsed.data.name] = {
name: parsed.data.name,
description: parsed.data.description,
state.skills[md.data.name] = {
name: md.data.name,
description: md.data.description,
location: match,
content: md.content,
}

View File

@@ -7,7 +7,6 @@ import { lazy } from "../util/lazy"
import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error"
import z from "zod"
import path from "path"
import { readFileSync, readdirSync, existsSync } from "fs"
import { Flag } from "@opencode-ai/core/flag/flag"
@@ -15,15 +14,13 @@ import { InstallationChannel } from "@opencode-ai/core/installation/version"
import { InstanceState } from "@/effect/instance-state"
import { iife } from "@/util/iife"
import { init } from "#db"
import { Schema } from "effect"
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined
export const NotFoundError = NamedError.create(
"NotFoundError",
z.object({
message: z.string(),
}),
)
export const NotFoundError = NamedError.create("NotFoundError", {
message: Schema.String,
})
const log = Log.create({ service: "db" })

View File

@@ -2,7 +2,6 @@ import * as Log from "@opencode-ai/core/util/log"
import path from "path"
import { Global } from "@opencode-ai/core/global"
import { NamedError } from "@opencode-ai/core/util/error"
import z from "zod"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect"
import { NonNegativeInt } from "@opencode-ai/core/schema"
@@ -16,12 +15,9 @@ type Migration = (
git: Git.Interface,
) => Effect.Effect<void, AppFileSystem.Error>
export const NotFoundError = NamedError.create(
"NotFoundError",
z.object({
message: z.string(),
}),
)
export const NotFoundError = NamedError.create("NotFoundError", {
message: Schema.String,
})
export type Error = AppFileSystem.Error | InstanceType<typeof NotFoundError>

View File

@@ -1,51 +1,9 @@
import { Schema } from "effect"
import { NamedError } from "@opencode-ai/core/util/error"
/**
* Create a Schema-backed NamedError-shaped class.
*
* Drop-in replacement for `NamedError.create(tag, zodShape)` but backed by
* `Schema.Struct` under the hood. The wire shape emitted by the derived
* `.Schema` is still `{ name: tag, data: {...fields} }` so the generated
* OpenAPI/SDK output is byte-identical to the original NamedError schema.
*
* Preserves the existing surface:
* - static `Schema` (Effect schema of the wire shape)
* - static `isInstance(x)`
* - instance `toObject()` returning `{ name, data }`
* - `new X({ ...data }, { cause })`
*/
export function namedSchemaError<Tag extends string, Fields extends Schema.Struct.Fields>(tag: Tag, fields: Fields) {
const dataSchema = Schema.Struct(fields)
// Wire shape matches the original NamedError output so the SDK stays stable.
const effectSchema = Schema.Struct({
name: Schema.Literal(tag),
data: dataSchema,
}).annotate({ identifier: tag })
type Data = Schema.Schema.Type<typeof dataSchema>
class NamedSchemaError extends Error {
static readonly Schema = effectSchema
static readonly EffectSchema = effectSchema
static readonly tag = tag
public static isInstance(input: unknown): input is NamedSchemaError {
return typeof input === "object" && input !== null && "name" in input && (input as { name: unknown }).name === tag
}
public override readonly name: Tag = tag
public readonly data: Data
constructor(data: Data, options?: ErrorOptions) {
super(tag, options)
this.data = data
}
toObject(): { name: Tag; data: Data } {
return { name: tag, data: this.data }
}
}
Object.defineProperty(NamedSchemaError, "name", { value: tag })
return NamedSchemaError
return NamedError.create(tag, fields)
}

View File

@@ -1,4 +1,3 @@
import z from "zod"
import { NamedError } from "@opencode-ai/core/util/error"
import { Global } from "@opencode-ai/core/global"
import { InstanceLayer } from "@/project/instance-layer"
@@ -65,54 +64,33 @@ export const ResetInput = Schema.Struct({
}).annotate({ identifier: "WorktreeResetInput" })
export type ResetInput = Schema.Schema.Type<typeof ResetInput>
export const NotGitError = NamedError.create(
"WorktreeNotGitError",
z.object({
message: z.string(),
}),
)
export const NotGitError = NamedError.create("WorktreeNotGitError", {
message: Schema.String,
})
export const NameGenerationFailedError = NamedError.create(
"WorktreeNameGenerationFailedError",
z.object({
message: z.string(),
}),
)
export const NameGenerationFailedError = NamedError.create("WorktreeNameGenerationFailedError", {
message: Schema.String,
})
export const CreateFailedError = NamedError.create(
"WorktreeCreateFailedError",
z.object({
message: z.string(),
}),
)
export const CreateFailedError = NamedError.create("WorktreeCreateFailedError", {
message: Schema.String,
})
export const StartCommandFailedError = NamedError.create(
"WorktreeStartCommandFailedError",
z.object({
message: z.string(),
}),
)
export const StartCommandFailedError = NamedError.create("WorktreeStartCommandFailedError", {
message: Schema.String,
})
export const RemoveFailedError = NamedError.create(
"WorktreeRemoveFailedError",
z.object({
message: z.string(),
}),
)
export const RemoveFailedError = NamedError.create("WorktreeRemoveFailedError", {
message: Schema.String,
})
export const ResetFailedError = NamedError.create(
"WorktreeResetFailedError",
z.object({
message: z.string(),
}),
)
export const ResetFailedError = NamedError.create("WorktreeResetFailedError", {
message: Schema.String,
})
export const ListFailedError = NamedError.create(
"WorktreeListFailedError",
z.object({
message: z.string(),
}),
)
export const ListFailedError = NamedError.create("WorktreeListFailedError", {
message: Schema.String,
})
function slugify(input: string) {
return input

View File

@@ -1,5 +1,9 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { NamedError } from "@opencode-ai/core/util/error"
import { errorData, errorFormat, errorMessage } from "../../src/util/error"
import { namedSchemaError } from "../../src/util/named-schema-error"
import { UI } from "../../src/cli/ui"
describe("util.error", () => {
test("formats native Error instances", () => {
@@ -48,4 +52,18 @@ describe("util.error", () => {
expect(data.message).toBe("ResolveMessage: Cannot resolve module")
expect(String(data.formatted)).toContain("ResolveMessage")
})
test("named schema errors are real NamedError instances", () => {
const ExampleError = namedSchemaError("ExampleError", { message: Schema.String })
const error = new ExampleError({ message: "boom" })
expect(error).toBeInstanceOf(NamedError)
expect(error.toObject()).toEqual({ name: "ExampleError", data: { message: "boom" } })
})
test("void named errors accept JSON without data", () => {
const serialized = JSON.parse(JSON.stringify(new UI.CancelledError(undefined).toObject()))
expect(Schema.decodeUnknownOption(UI.CancelledError.Schema)(serialized)._tag).toBe("Some")
})
})