Skip to content

Commit 2d5d80f

Browse files
mowtwocodex
andcommitted
fix: explain type mismatch fields
Co-authored-by: Codex <codex@openai.com>
1 parent e8446eb commit 2d5d80f

2 files changed

Lines changed: 93 additions & 4 deletions

File tree

‎packages/std/src/type.ts‎

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,80 @@ export function is(value: unknown, descriptor: TypeDescriptor): boolean {
383383
}
384384
}
385385

386+
function describeDescriptor(descriptor: TypeDescriptor): string {
387+
if (descriptor.kind === 'struct') {
388+
const fields = descriptor.fields ?? []
389+
const shape = `{ ${fields
390+
.map((f) => `${f.name}${f.optional ? '?' : ''}: ${describeDescriptor(f.type)}`)
391+
.join('; ')} }`
392+
return descriptor.name === '__anon' ? shape : `${descriptor.name} ${shape}`
393+
}
394+
if (descriptor.kind === 'array') {
395+
return `${describeDescriptor(descriptor.element ?? Object_)}[]`
396+
}
397+
if (descriptor.kind === 'optional') {
398+
return `${describeDescriptor(descriptor.inner ?? Object_)}?`
399+
}
400+
return descriptor.name
401+
}
402+
403+
function describeActual(value: unknown): string {
404+
return describeDescriptor(of(value))
405+
}
406+
407+
function pathLabel(path: string): string {
408+
return path === '$' ? 'value' : path.slice(2)
409+
}
410+
411+
function explainMismatchAt(value: unknown, descriptor: TypeDescriptor, path: string): string | null {
412+
switch (descriptor.kind) {
413+
case 'struct': {
414+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
415+
return `${pathLabel(path)} expected ${describeDescriptor(descriptor)}, got ${describeActual(value)}`
416+
}
417+
for (const field of descriptor.fields ?? []) {
418+
const record = value as Record<string, unknown>
419+
const present = field.name in record
420+
const fieldPath = `${path}.${field.name}`
421+
if (field.optional) {
422+
if (!present || record[field.name] === null || record[field.name] === undefined) continue
423+
} else if (!present) {
424+
return `${pathLabel(fieldPath)} is missing; expected ${describeDescriptor(field.type)}`
425+
}
426+
const nested = explainMismatchAt(record[field.name], field.type, fieldPath)
427+
if (nested) return nested
428+
}
429+
return null
430+
}
431+
case 'array': {
432+
if (!Array.isArray(value)) {
433+
return `${pathLabel(path)} expected ${describeDescriptor(descriptor)}, got ${describeActual(value)}`
434+
}
435+
const element = descriptor.element
436+
if (!element) return null
437+
for (let i = 0; i < value.length; i++) {
438+
const nested = explainMismatchAt(value[i], element, `${path}[${i}]`)
439+
if (nested) return nested
440+
}
441+
return null
442+
}
443+
case 'optional':
444+
if (value === null || value === undefined) return null
445+
return descriptor.inner ? explainMismatchAt(value, descriptor.inner, path) : null
446+
default:
447+
return is(value, descriptor)
448+
? null
449+
: `${pathLabel(path)} expected ${describeDescriptor(descriptor)}, got ${describeActual(value)}`
450+
}
451+
}
452+
453+
function formatMismatch(prefix: 'type.as' | 'type.tryFrom', value: unknown, descriptor: TypeDescriptor): string {
454+
const root = `expected ${describeDescriptor(descriptor)}, got ${describeActual(value)}`
455+
const detail = explainMismatchAt(value, descriptor, '$')
456+
if (!detail || detail === `value ${root}`) return `${prefix}: ${root}`
457+
return `${prefix}: ${root}; ${detail}`
458+
}
459+
386460
/**
387461
* Thrown by `type.as` when the input value (after the optional
388462
* `castFn`) doesn't match the target descriptor. Caller can `try { … }
@@ -436,9 +510,8 @@ export function as<T = unknown>(
436510
): T {
437511
const v = castFn ? castFn(value) : value
438512
if (!is(v, descriptor)) {
439-
const got = of(v)
440513
throw new TypeMismatchError(
441-
`type.as: expected ${descriptor.name}, got ${got.name}`,
514+
formatMismatch('type.as', v, descriptor),
442515
descriptor,
443516
v
444517
)
@@ -470,11 +543,10 @@ export function tryFrom<T = unknown>(
470543
}
471544
}
472545
if (!is(v, descriptor)) {
473-
const got = of(v)
474546
return {
475547
ok: false,
476548
error: new TypeMismatchError(
477-
`type.tryFrom: expected ${descriptor.name}, got ${got.name}`,
549+
formatMismatch('type.tryFrom', v, descriptor),
478550
descriptor,
479551
v
480552
),

‎packages/std/tests/type.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,22 @@ describe('@tu-lang/std/type — type.as (M9 Phase C strict-cast)', () => {
174174
const User = struct('User', [{ name: 'id', type: type.Number }])
175175
expect(() => type.as({ id: 'not a number' }, User)).toThrow(TypeMismatchError)
176176
expect(() => type.as({ id: 'not a number' }, User)).toThrow(/expected User/i)
177+
expect(() => type.as({ id: 'not a number' }, User)).toThrow(
178+
/id expected number, got string/i
179+
)
180+
})
181+
182+
it('2-arg form: explains missing and nested fields', () => {
183+
const Profile = struct('Profile', [
184+
{ name: 'name', type: type.String },
185+
{ name: 'contact', type: struct('Contact', [{ name: 'email', type: type.String }]) },
186+
])
187+
expect(() => type.as({ contact: { email: 'a@example.com' } }, Profile)).toThrow(
188+
/name is missing; expected string/i
189+
)
190+
expect(() => type.as({ name: 'Ada', contact: { email: 42 } }, Profile)).toThrow(
191+
/contact\.email expected string, got number/i
192+
)
177193
})
178194

179195
it('3-arg form: castFn runs first, then the shape check', () => {
@@ -239,6 +255,7 @@ describe('@tu-lang/std/type — type.tryFrom non-throwing conversion', () => {
239255
expect(result.error).toBeInstanceOf(TypeMismatchError)
240256
expect(result.error.expected).toBe(User)
241257
expect(result.error.message).toMatch(/expected User/i)
258+
expect(result.error.message).toMatch(/id expected number, got string/i)
242259
}
243260
})
244261

0 commit comments

Comments
 (0)