diff --git a/src/components/form/ConverterPage.tsx b/src/components/form/ConverterPage.tsx index 6cfeb2c..3877665 100644 --- a/src/components/form/ConverterPage.tsx +++ b/src/components/form/ConverterPage.tsx @@ -1,6 +1,6 @@ import { useStore } from '@tanstack/react-form' import { useConverterForm } from '@/hooks/useConverterForm' -import type { ConverterConfig, ConverterFormBase, SelectOption } from '@/lib/converter-configs' +import type { ConverterConfig, ConverterFormBase, ConverterMode, SelectOption } from '@/lib/converter-configs' import { FormTextArea } from './FormTextArea' interface ConverterPageProps { @@ -21,7 +21,7 @@ export function ConverterPage({ } = useConverterForm(config) // Selective subscription: only re-render when mode changes - const mode: string = useStore(form.store, (state) => state.values.mode) + const mode: ConverterMode = useStore(form.store, (state) => state.values.mode) return (
@@ -44,20 +44,12 @@ export function ConverterPage({ ) if (!visible) return null - // Resolve dynamic properties const resolvedLabel = field.isInput ? config.inputLabel(mode) : field.label - const resolvedPlaceholder = - typeof field.placeholder === 'function' - ? field.placeholder(mode) - : field.placeholder - const resolvedClassName = - typeof field.className === 'function' - ? field.className(mode) - : field.className if (field.type === 'select') { + // TypeScript narrows to SelectFieldConfig here const resolvedOptions: SelectOption[] = field.options === 'encodings' ? encodingOptions @@ -81,7 +73,16 @@ export function ConverterPage({ ) } - // textarea + // Resolve textarea-specific dynamic properties (narrowed to TextAreaFieldConfig) + const resolvedPlaceholder = + typeof field.placeholder === 'function' + ? field.placeholder(mode) + : field.placeholder + const resolvedClassName = + typeof field.className === 'function' + ? field.className(mode) + : field.className + return ( {(fieldApi) => ( diff --git a/src/components/form/FieldErrorMessage.tsx b/src/components/form/FieldErrorMessage.tsx index 3673a83..69e5812 100644 --- a/src/components/form/FieldErrorMessage.tsx +++ b/src/components/form/FieldErrorMessage.tsx @@ -1,7 +1,8 @@ import { formatFieldErrors } from '@/lib/errors' +import type { StandardSchemaIssue } from '@/lib/errors' interface FieldErrorMessageProps { - meta: { isTouched?: boolean; isBlurred?: boolean; errors?: unknown[] } + meta: { isTouched?: boolean; isBlurred?: boolean; errors?: StandardSchemaIssue[] } showWhenSubmitted: boolean } diff --git a/src/hooks/useConverterForm.ts b/src/hooks/useConverterForm.ts index 18bcf12..cdb4813 100644 --- a/src/hooks/useConverterForm.ts +++ b/src/hooks/useConverterForm.ts @@ -1,11 +1,12 @@ import { useCallback, useMemo, useRef, useState } from 'react' import { POPULAR_ENCODINGS } from '@/lib/encoding' import { getErrorMessage } from '@/lib/errors' +import type { StandardSchemaIssue } from '@/lib/errors' import { useAppForm } from '@/hooks/form' import type { ConverterConfig, ConverterFormBase, SelectOption } from '@/lib/converter-configs' interface FieldMetaLike { - errors?: unknown[] + errors?: StandardSchemaIssue[] } export function useConverterForm( diff --git a/src/lib/converter-configs.ts b/src/lib/converter-configs.ts index 226a2b4..d5bb015 100644 --- a/src/lib/converter-configs.ts +++ b/src/lib/converter-configs.ts @@ -1,4 +1,4 @@ -import type { ZodType } from 'zod' +import type { ZodType, ZodTypeDef } from 'zod' import { Binary, Base64, Hex, URLEncode, isValidEncoding } from '@/lib/encoding' import { binaryConverterSchema, @@ -15,44 +15,64 @@ import { // Shared types // --------------------------------------------------------------------------- +export type ConverterMode = 'encode' | 'decode' + export interface SelectOption { value: string label: string } export interface ConverterFormBase { - mode: string + mode: ConverterMode input: string } -export interface FieldConfig> { +interface FieldConfigBase { /** Field name — must match a key in the form's default values */ - name: string - /** Render type */ - type: 'select' | 'textarea' + name: string & keyof T /** Label text (static) */ label: string + /** Show this field only when the predicate returns true */ + visibleWhen?: (values: T) => boolean + /** Marks this field as the primary input (receives registerInputRef and dynamic label) */ + isInput?: boolean +} + +export interface SelectFieldConfig> extends FieldConfigBase { + /** Discriminant — render as a dropdown */ + type: 'select' /** Options for select fields; 'encodings' resolves to the encoding list at render time */ options?: SelectOption[] | 'encodings' + /** @internal Discriminated-union guard — prevents cross-variant property assignment */ + rows?: never + /** @internal Discriminated-union guard — prevents cross-variant property assignment */ + placeholder?: never + /** @internal Discriminated-union guard — prevents cross-variant property assignment */ + className?: never +} + +export interface TextAreaFieldConfig> extends FieldConfigBase { + /** Discriminant — render as a multi-line text area */ + type: 'textarea' /** Textarea row count */ rows?: number /** Static or mode-dependent placeholder */ - placeholder?: string | ((mode: string) => string) - /** Show this field only when the predicate returns true */ - visibleWhen?: (values: T) => boolean - /** Marks this field as the primary input (receives registerInputRef and dynamic label) */ - isInput?: boolean + placeholder?: string | ((mode: ConverterMode) => string) /** Static or mode-dependent className */ - className?: string | ((mode: string) => string | undefined) + className?: string | ((mode: ConverterMode) => string | undefined) + /** @internal Discriminated-union guard — prevents cross-variant property assignment */ + options?: never } +export type FieldConfig> = SelectFieldConfig | TextAreaFieldConfig + export interface ConverterConfig { /** Page heading */ title: string /** Page sub-heading */ description: string /** Zod schema for form-level validation */ - schema: ZodType + schema: ZodType /** TanStack Form default values */ defaultValues: T /** Ordered list of form fields */ @@ -60,9 +80,9 @@ export interface ConverterConfig { /** Conversion logic — returns the result string */ onSubmit: (values: T) => Promise /** Dynamic label for the primary input field */ - inputLabel: (mode: string) => string + inputLabel: (mode: ConverterMode) => string /** Dynamic label for the output area */ - outputLabel: (mode: string) => string + outputLabel: (mode: ConverterMode) => string } // --------------------------------------------------------------------------- diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 8ee174c..a7aaa0c 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -7,21 +7,21 @@ */ export interface StandardSchemaIssue { message: string - path?: (string | number)[] + path?: ReadonlyArray [key: string]: unknown } /** * Extract error messages from form field errors */ -export function formatFieldErrors(errors: unknown[]): string { - return errors.map((err) => (err as StandardSchemaIssue).message).join(', ') +export function formatFieldErrors(errors: StandardSchemaIssue[]): string { + return errors.map((err) => err.message).join(', ') } export class EncodingError extends Error { - public readonly code: string + public readonly code: ErrorCode - constructor(message: string, code: string) { + constructor(message: string, code: ErrorCode) { super(message) this.name = 'EncodingError' this.code = code @@ -52,6 +52,8 @@ export const ERROR_CODES = { DECODE_FAILED: 'DECODE_FAILED', } as const +export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES] + /** * Get user-friendly error message from error object */ @@ -72,8 +74,16 @@ export function getErrorMessage(error: unknown): string { return `Invalid hexadecimal format. Expected hex digits (0-9, a-f). ${error.message}` case ERROR_CODES.DECODE_FAILED: return `Failed to decode data. ${error.message}` - default: + case ERROR_CODES.ENCODE_FAILED: + return `Failed to encode data. ${error.message}` + case ERROR_CODES.EMPTY_INPUT: + case ERROR_CODES.INPUT_TOO_LARGE: + return error.message + default: { + const _exhaustive: never = error.code + void _exhaustive return error.message + } } } diff --git a/src/lib/validation-schemas.ts b/src/lib/validation-schemas.ts index d858d63..6c683f8 100644 --- a/src/lib/validation-schemas.ts +++ b/src/lib/validation-schemas.ts @@ -150,7 +150,7 @@ const modeSchema = z.enum(['encode', 'decode']) function conditionalInputValidation( decodeSchema: z.ZodType, ) { - return (data: { mode: string; input: string }, ctx: RefinementCtx) => { + return (data: { mode: 'encode' | 'decode'; input: string }, ctx: RefinementCtx) => { const schema = data.mode === 'encode' ? baseInputValidation : decodeSchema const result = schema.safeParse(data.input) if (!result.success) { @@ -195,7 +195,7 @@ export const hexConverterSchema = z.object({ * URL Encoder form schema */ export const urlEncoderSchema = z.object({ - mode: z.enum(['encode', 'decode']), + mode: modeSchema, encoding: encodingSchema, encodingMode: z.enum(['component', 'full']), input: z.string(),