/** * MIT License * * Copyright (c) 2025 Chris M. Perez * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ import { Schema, Effect, Either, Exit, Cause, ParseResult, Array as Arr, Option, Predicate, pipe, } from 'effect'; import { Data } from 'effect'; import { CauseExtractionError } from '../errors.js'; export class PropsValidationError extends Data.TaggedError( 'PropsValidationError' )<{ readonly propName: string; readonly componentName: string | undefined; readonly message: string; readonly cause?: unknown; }> { override toString(): string { return `PropsValidationError: Props validation failed for "${this.propName}": ${this.message}`; } } export interface PropDefinition { readonly schema: Schema.Schema; readonly required: boolean; readonly defaultValue?: T; readonly _tag: 'PropDefinition'; } export interface PropSchemaBuilder> { readonly _schema: { [K in keyof T]: PropDefinition; }; readonly schema: Schema.Schema; readonly validate: ( props: unknown, componentName?: string ) => Effect.Effect; readonly validateSync: (props: unknown, componentName?: string) => T; } export interface AnyPropSchemaBuilder { readonly validateSync: (props: unknown, componentName?: string) => unknown; } type ExtractPropType

= P extends PropDefinition ? T : never; // Build required prop definition function required(schema: Schema.Schema): PropDefinition; function required>( builder: PropSchemaBuilder ): PropDefinition; function required( schemaOrBuilder: Schema.Schema | PropSchemaBuilder> ): PropDefinition { if ( Predicate.isObject(schemaOrBuilder) && Predicate.hasProperty(schemaOrBuilder, 'validate') && Predicate.hasProperty(schemaOrBuilder, 'schema') ) { const builder = schemaOrBuilder; return { schema: builder.schema as unknown as Schema.Schema, required: true, _tag: 'PropDefinition', }; } return { schema: schemaOrBuilder, required: true, _tag: 'PropDefinition', }; } function optional( schema: Schema.Schema, defaultValue?: T ): PropDefinition; function optional>( builder: PropSchemaBuilder, defaultValue?: T ): PropDefinition; function optional( schemaOrBuilder: | Schema.Schema | PropSchemaBuilder>, defaultValue?: T ): PropDefinition { let baseSchema: Schema.Schema; if ( Predicate.isObject(schemaOrBuilder) && Predicate.hasProperty(schemaOrBuilder, 'validate') && Predicate.hasProperty(schemaOrBuilder, 'schema') ) { const builder = schemaOrBuilder; baseSchema = builder.schema as unknown as Schema.Schema; } else { baseSchema = schemaOrBuilder; } const schema = (defaultValue !== undefined ? Schema.optional(baseSchema).pipe( Schema.withDecodingDefault( () => defaultValue as unknown as Exclude ) ) : Schema.optional(baseSchema)) as unknown as Schema.Schema; return { schema, required: false, defaultValue, _tag: 'PropDefinition', }; } // Build property structure definition // eslint-disable-next-line @typescript-eslint/no-explicit-any const struct = >>( definitions: D ): PropSchemaBuilder<{ [K in keyof D]: ExtractPropType }> => { type ResultType = { [K in keyof D]: ExtractPropType }; const schemaFields: Record> = {}; for (const [key, def] of Object.entries(definitions)) { schemaFields[key] = def.schema; } const compositeSchema = Schema.Struct(schemaFields); const validate = ( props: unknown, componentName?: string ): Effect.Effect => Effect.gen(function* () { if (!Predicate.isObject(props)) { return yield* Effect.fail( new PropsValidationError({ propName: 'props', componentName, message: 'Props must be an object', }) ); } const parseResult = Schema.decodeUnknownEither(compositeSchema)(props); if (Either.isLeft(parseResult)) { const error = parseResult.left; const issues = ParseResult.ArrayFormatter.formatErrorSync(error); const { propName, message } = pipe( Arr.head(issues), Option.match({ onNone: () => ({ propName: 'unknown', message: 'Invalid prop value', }), onSome: (issue) => ({ propName: issue.path.join('.'), message: issue.message, }), }) ); return yield* Effect.fail( new PropsValidationError({ propName, componentName, message, cause: error, }) ); } return parseResult.right as ResultType; }); const validateSync = (props: unknown, componentName?: string): ResultType => { const exit = Effect.runSyncExit(validate(props, componentName)); if (Exit.isFailure(exit)) { const cause = exit.cause; const failure = Cause.failureOption(cause); if (failure._tag === 'Some') { throw failure.value; } throw new CauseExtractionError({ cause }); } return exit.value; }; return { _schema: definitions as { [K in keyof ResultType]: PropDefinition; }, schema: compositeSchema as unknown as Schema.Schema, validate, validateSync, }; }; export const PropSchema = { required, optional, struct, String: Schema.String, Number: Schema.Number, Boolean: Schema.Boolean, Literal: Schema.Literal, Union: Schema.Union, Array: Schema.Array, Struct: Schema.Struct, Unknown: Schema.Unknown, Optional: Schema.optional, }; export type PropSchemaInfer = S extends PropSchemaBuilder ? T : never;