@beignet/core
Version:
Core framework primitives for Beignet
146 lines (133 loc) • 4.09 kB
text/typescript
import type { StandardSchemaV1 } from "@standard-schema/spec";
/**
* Any Standard Schema compatible validator.
*/
export type StandardSchema = StandardSchemaV1<unknown, unknown>;
/**
* Infer the parsed output type from a Standard Schema.
*/
export type InferOutput<T extends StandardSchemaV1> =
StandardSchemaV1.InferOutput<T>;
/**
* Validate data using a Standard Schema validator
*/
async function validateSchema<T>(
schema: StandardSchemaV1<unknown, T>,
data: unknown,
): Promise<T> {
const result = await schema["~standard"].validate(data);
if (result.issues?.length) {
const message = result.issues
.map((issue) => {
const path =
issue.path !== undefined
? Array.isArray(issue.path)
? issue.path.join(".")
: String(issue.path)
: "";
return path ? `${path}: ${issue.message}` : issue.message;
})
.join("; ");
throw new Error(`Validation failed: ${message}`);
}
if ("value" in result) {
return result.value;
}
throw new Error("Invalid Standard Schema result: missing value");
}
/**
* Check if data is valid according to a Standard Schema
*/
async function isValid<T>(
schema: StandardSchemaV1<unknown, T>,
data: unknown,
): Promise<boolean> {
const result = await schema["~standard"].validate(data);
return !result.issues?.length && "value" in result;
}
/**
* Value object definition returned by `defineValueObject(...).build()`.
*/
export interface ValueObjectDef<
Name extends string,
Schema extends StandardSchema,
> {
/** Name used for debugging and introspection. */
name: Name;
/** Standard Schema used to validate values. */
schema: Schema;
/**
* Create a validated value from unknown input.
*
* This is async because Standard Schema validators may be async.
*/
create(input: unknown): Promise<InferOutput<Schema>>;
/**
* Check whether an input is valid for this value object.
*/
isValid(input: unknown): Promise<boolean>;
/** Type-only alias for the inferred TypeScript type. Undefined at runtime. */
Type: InferOutput<Schema>;
}
/**
* Builder class for creating Value Objects.
*/
class ValueObjectBuilder<Name extends string, Schema extends StandardSchema> {
constructor(
private readonly cfg: {
name: Name;
schema?: Schema;
},
) {}
/**
* Set the schema for this value object.
*/
schema<S extends StandardSchema>(s: S): ValueObjectBuilder<Name, S> {
return new ValueObjectBuilder<Name, S>({ ...this.cfg, schema: s });
}
/**
* Finalize the value object definition.
*/
build(): ValueObjectDef<Name, Schema> {
if (!this.cfg.schema) {
throw new Error(`Value object "${this.cfg.name}" is missing a schema`);
}
const schemaToUse = this.cfg.schema;
const def: ValueObjectDef<Name, Schema> = {
name: this.cfg.name,
schema: schemaToUse,
async create(input: unknown) {
return validateSchema(schemaToUse, input);
},
async isValid(input: unknown) {
return isValid(schemaToUse, input);
},
// Type is undefined at runtime - used only for TypeScript type inference via `typeof ValueObject.Type`
Type: undefined as unknown as InferOutput<Schema>,
};
return def;
}
}
/**
* Create a new value object builder.
*
* Value objects are schema-backed primitives for domain concepts such as email
* addresses or money values. Beignet does not add a runtime brand; the schema
* output controls the runtime value.
*
* @example
* ```ts
* const Email = defineValueObject("Email")
* .schema(z.string().email())
* .build();
*
* type Email = typeof Email.Type;
*
* const email = await Email.create("test@example.com"); // OK
* const isValid = await Email.isValid("test@example.com"); // true
* ```
*/
export function defineValueObject<Name extends string>(name: Name) {
// biome-ignore lint/suspicious/noExplicitAny: Initial schema type is never, will be set via .schema()
return new ValueObjectBuilder<Name, never>({ name } as any);
}