import { it, describe, expect } from 'vitest';
import { Effect, Schema } from 'effect';
import {
ESchema,
ValueESchema,
toSchema,
type AnyESchema,
type AnyEvolvingSchema,
type AnyValueESchema,
type ESchemaEncoded,
type ESchemaType,
} from '../index.js';
import { ESchemaError } from '../index.js';
const itEffect = (name: string, fn: () => Effect.Effect) =>
it(name, () => Effect.runPromise(fn()));
describe('ESchema', () => {
describe('Value', () => {
describe('Make', () => {
itEffect('encodes values with a value envelope', () =>
Effect.gen(function* () {
const schema = ValueESchema.make('Count', Schema.Number).build();
const encoded = yield* schema.encode(42);
expect(encoded).toEqual({ _v: 'v1', value: 42 });
}),
);
itEffect('decodes value envelopes', () =>
Effect.gen(function* () {
const schema = ValueESchema.make('Count', Schema.Number).build();
const decoded = yield* schema.decode({ _v: 'v1', value: 42 });
expect(decoded).toBe(42);
}),
);
itEffect('treats bare values as earliest-version data', () =>
Effect.gen(function* () {
const schema = ValueESchema.make('Count', Schema.String)
.evolve('v2', Schema.Number, (value) => Number(value))
.build();
const decoded = yield* schema.decode('42');
expect(decoded).toBe(42);
}),
);
itEffect('fails on unknown envelope version', () =>
Effect.gen(function* () {
const schema = ValueESchema.make('Label', Schema.String).build();
const error = yield* Effect.flip(
schema.decode({ _v: 'v99', value: 'hello' }),
);
expect(error).toBeInstanceOf(ESchemaError);
expect(error.message).toBe('Unknown schema version: v99');
}),
);
itEffect('does not treat { value } as an unstamped envelope', () =>
Effect.gen(function* () {
const schema = ValueESchema.make('Label', Schema.String).build();
const error = yield* Effect.flip(schema.decode({ value: 'hello' }));
expect(error.message).toBe('Decode failed');
}),
);
itEffect('treats envelope-shaped objects as envelopes', () =>
Effect.gen(function* () {
const schema = ValueESchema.make(
'Envelope',
Schema.Struct({
_v: Schema.String,
value: Schema.String,
}),
).build();
const error = yield* Effect.flip(
schema.decode({ _v: 'business-version', value: 'hello' }),
);
expect(error.message).toBe(
'Unknown schema version: business-version',
);
}),
);
itEffect('allows underscore-prefixed keys inside enveloped values', () =>
Effect.gen(function* () {
const schema = ValueESchema.make(
'Payload',
Schema.Struct({
_source: Schema.String,
name: Schema.String,
}),
).build();
const decoded = yield* schema.decode({
_v: 'v1',
value: { _source: 'import', name: 'Alice' },
});
const encoded = yield* schema.encode(decoded);
expect(decoded).toEqual({ _source: 'import', name: 'Alice' });
expect(encoded).toEqual({
_v: 'v1',
value: { _source: 'import', name: 'Alice' },
});
}),
);
});
describe('Evolve', () => {
itEffect('migrates through whole-value schema replacements', () =>
Effect.gen(function* () {
const schema = ValueESchema.make(
'Status',
Schema.Literals(['draft', 'published']),
)
.evolve(
'v2',
Schema.Literals(['draft', 'review', 'published']),
(value) => value,
)
.build();
const decoded = yield* schema.decode({ _v: 'v1', value: 'draft' });
const encoded = yield* schema.encode('review');
expect(decoded).toBe('draft');
expect(encoded).toEqual({ _v: 'v2', value: 'review' });
}),
);
itEffect('migrations receive decoded values from prior versions', () =>
Effect.gen(function* () {
const schema = ValueESchema.make('Count', Schema.Number)
.evolve('v2', Schema.Number, (value) => value * 2)
.build();
const decoded = yield* schema.decode({ _v: 'v1', value: 21 });
expect(decoded).toBe(42);
}),
);
});
describe('Composition', () => {
const Status = ValueESchema.make(
'Status',
Schema.Literals(['draft', 'published']),
)
.evolve(
'v2',
Schema.Literals(['draft', 'review', 'published']),
(value) => value,
)
.build();
const Ticket = ESchema.make('Ticket', {
title: Schema.String,
status: toSchema(Status),
}).build();
itEffect('encodes nested values as envelopes', () =>
Effect.gen(function* () {
const encoded = yield* Ticket.encode({
title: 'Fix billing',
status: 'review',
});
expect(encoded).toEqual({
_v: 'v1',
title: 'Fix billing',
status: { _v: 'v2', value: 'review' },
});
}),
);
itEffect('decodes nested bare legacy values', () =>
Effect.gen(function* () {
const decoded = yield* Ticket.decode({
_v: 'v1',
title: 'Fix billing',
status: 'draft',
});
expect(decoded).toEqual({
title: 'Fix billing',
status: 'draft',
});
}),
);
itEffect('decodes nested value envelopes independently', () =>
Effect.gen(function* () {
const decoded = yield* Ticket.decode({
_v: 'v1',
title: 'Fix billing',
status: { _v: 'v1', value: 'published' },
});
expect(decoded).toEqual({
title: 'Fix billing',
status: 'published',
});
}),
);
});
describe('Views', () => {
it('schema exposes the latest value schema', () => {
const schema = ValueESchema.make('Count', Schema.String)
.evolve('v2', Schema.Number, (value) => Number(value))
.build();
expect(Schema.isSchema(schema.schema)).toBe(true);
});
it('getDescriptor describes the canonical envelope', () => {
const schema = ValueESchema.make('Count', Schema.Number).build();
const descriptor = schema.getDescriptor();
const versionSchema = descriptor.properties._v as { enum?: string[] };
expect(descriptor.type).toBe('object');
expect(versionSchema.enum).toEqual(['v1']);
expect(descriptor.properties).toHaveProperty('value');
});
it('Standard Schema validate follows the read path', () => {
const schema = ValueESchema.make('Label', Schema.String).build();
expect(schema['~standard'].validate('draft')).toEqual({
value: 'draft',
});
expect(
schema['~standard'].validate({ _v: 'v1', value: 'draft' }),
).toEqual({
value: 'draft',
});
});
});
describe('Type extractors', () => {
it('extracts decoded and encoded value types', () => {
const schema = ValueESchema.make('Count', Schema.Number).build();
type Decoded = ESchemaType;
type Encoded = ESchemaEncoded;
const decoded: Decoded = 42;
const encoded: Encoded = { _v: 'v1', value: 42 };
// @ts-expect-error — encoded value must carry the envelope's _v
const invalidEncoded: Encoded = { value: 42 };
expect(decoded).toBe(42);
expect(encoded).toEqual({ _v: 'v1', value: 42 });
void invalidEncoded;
});
it('keeps value and object-shaped widening types separate', () => {
const objectSchema = ESchema.make('User', {
name: Schema.String,
}).build();
const valueSchema = ValueESchema.make('Label', Schema.String).build();
function acceptsAnyESchema(schema: AnyESchema) {
return schema.getDescriptor();
}
function acceptsValueSchema(schema: AnyValueESchema) {
return schema.getDescriptor();
}
function acceptsEvolvingSchema(schema: AnyEvolvingSchema) {
return schema.getDescriptor();
}
const objectDescriptor = acceptsAnyESchema(objectSchema);
const valueDescriptor = acceptsValueSchema(valueSchema);
const anyObjectDescriptor = acceptsEvolvingSchema(objectSchema);
const anyValueDescriptor = acceptsEvolvingSchema(valueSchema);
function assertTypeErrors() {
// @ts-expect-error — AnyESchema remains object-shaped
acceptsAnyESchema(valueSchema);
// @ts-expect-error — ValueESchema has no fields API
const fields = valueSchema.fields;
// @ts-expect-error — ValueESchema has no partial field-map helper
valueSchema.makePartial({});
void fields;
}
void assertTypeErrors;
expect(objectDescriptor.type).toBe('object');
expect(valueDescriptor.type).toBe('object');
expect(anyObjectDescriptor.type).toBe('object');
expect(anyValueDescriptor.type).toBe('object');
});
});
});
});