From 37709f6515880c4af6a4d5289946fd662ccaabdb Mon Sep 17 00:00:00 2001 From: Mattias Carlsson Date: Wed, 4 Mar 2026 12:26:16 +0100 Subject: [PATCH 1/9] chore(types): add ErrorCode literal type with exhaustive switch Extract ErrorCode from ERROR_CODES const, narrow EncodingError.code from string to ErrorCode, add never-typed exhaustive default in getErrorMessage, and add missing cases for ENCODE_FAILED, EMPTY_INPUT, INPUT_TOO_LARGE. --- src/lib/errors.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 8ee174c..88bcdc0 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -19,9 +19,9 @@ export function formatFieldErrors(errors: unknown[]): string { } 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,17 @@ 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: + return `Input cannot be empty. ${error.message}` + case ERROR_CODES.INPUT_TOO_LARGE: + return `Input is too large. ${error.message}` + default: { + const _exhaustive: never = error.code + void _exhaustive return error.message + } } } From 2063148631013129f9268019f58465689d6bfa61 Mon Sep 17 00:00:00 2001 From: Mattias Carlsson Date: Wed, 4 Mar 2026 12:27:03 +0100 Subject: [PATCH 2/9] chore(types): replace unknown[] with StandardSchemaIssue[] for form errors Tighten formatFieldErrors, FieldMetaLike.errors, and FieldErrorMessage meta.errors from unknown[] to StandardSchemaIssue[], removing the unsafe cast in formatFieldErrors. --- src/components/form/FieldErrorMessage.tsx | 3 ++- src/hooks/useConverterForm.ts | 3 ++- src/lib/errors.ts | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) 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/errors.ts b/src/lib/errors.ts index 88bcdc0..239e3b7 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -14,8 +14,8 @@ export interface StandardSchemaIssue { /** * 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 { From 979c7dbb6ae228020ae3e56f5638bab6aadab556 Mon Sep 17 00:00:00 2001 From: Mattias Carlsson Date: Wed, 4 Mar 2026 12:27:43 +0100 Subject: [PATCH 3/9] chore(types): narrow ConverterFormBase.mode to ConverterMode literal union Define ConverterMode = 'encode' | 'decode', update ConverterFormBase, FieldConfig callbacks, and ConverterConfig function signatures. Update ConverterPage to use ConverterMode annotation on useStore result. --- src/components/form/ConverterPage.tsx | 4 ++-- src/lib/converter-configs.ts | 12 +++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/components/form/ConverterPage.tsx b/src/components/form/ConverterPage.tsx index 6cfeb2c..e4268ab 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 (
diff --git a/src/lib/converter-configs.ts b/src/lib/converter-configs.ts index 226a2b4..3f6b38c 100644 --- a/src/lib/converter-configs.ts +++ b/src/lib/converter-configs.ts @@ -15,13 +15,15 @@ import { // Shared types // --------------------------------------------------------------------------- +export type ConverterMode = 'encode' | 'decode' + export interface SelectOption { value: string label: string } export interface ConverterFormBase { - mode: string + mode: ConverterMode input: string } @@ -37,13 +39,13 @@ export interface FieldConfig> { /** Textarea row count */ rows?: number /** Static or mode-dependent placeholder */ - placeholder?: string | ((mode: string) => string) + placeholder?: string | ((mode: ConverterMode) => 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 /** Static or mode-dependent className */ - className?: string | ((mode: string) => string | undefined) + className?: string | ((mode: ConverterMode) => string | undefined) } export interface ConverterConfig { @@ -60,9 +62,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 } // --------------------------------------------------------------------------- From 05fdedbb228759304bf9c7b72869249cd6445011 Mon Sep 17 00:00:00 2001 From: Mattias Carlsson Date: Wed, 4 Mar 2026 12:28:33 +0100 Subject: [PATCH 4/9] chore(types): make FieldConfig a discriminated union (select | textarea) Split FieldConfig into SelectFieldConfig and TextAreaFieldConfig extending a shared FieldConfigBase. Use `string & keyof T` for field names. Update ConverterPage to narrow via field.type before accessing type-specific props. --- src/components/form/ConverterPage.tsx | 21 +++++++++++---------- src/lib/converter-configs.ts | 24 ++++++++++++++++-------- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src/components/form/ConverterPage.tsx b/src/components/form/ConverterPage.tsx index e4268ab..92627a5 100644 --- a/src/components/form/ConverterPage.tsx +++ b/src/components/form/ConverterPage.tsx @@ -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 + // TypeScript narrows to TextAreaFieldConfig here + 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/lib/converter-configs.ts b/src/lib/converter-configs.ts index 3f6b38c..1beb649 100644 --- a/src/lib/converter-configs.ts +++ b/src/lib/converter-configs.ts @@ -27,27 +27,35 @@ export interface ConverterFormBase { 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 { + type: 'select' /** Options for select fields; 'encodings' resolves to the encoding list at render time */ options?: SelectOption[] | 'encodings' +} + +export interface TextAreaFieldConfig> extends FieldConfigBase { + type: 'textarea' /** Textarea row count */ rows?: number /** Static or mode-dependent placeholder */ placeholder?: string | ((mode: ConverterMode) => 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 /** Static or mode-dependent className */ className?: string | ((mode: ConverterMode) => string | undefined) } +export type FieldConfig> = SelectFieldConfig | TextAreaFieldConfig + export interface ConverterConfig { /** Page heading */ title: string From 6cbba81ac3117ee38783d0b24ae16cc0f91db1e3 Mon Sep 17 00:00:00 2001 From: Mattias Carlsson Date: Wed, 4 Mar 2026 12:29:15 +0100 Subject: [PATCH 5/9] chore(types): tighten ConverterConfig.schema to ZodType Link schema output type to the config's generic T parameter so mismatched schemas produce a compile error. ZodEffects (from superRefine) extends ZodType, so all 4 converter schemas satisfy the constraint. --- src/lib/converter-configs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/converter-configs.ts b/src/lib/converter-configs.ts index 1beb649..344ef74 100644 --- a/src/lib/converter-configs.ts +++ b/src/lib/converter-configs.ts @@ -62,7 +62,7 @@ export interface ConverterConfig { /** 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 */ From 3e45b8f430092733b21f1818ea88f810a3c50f40 Mon Sep 17 00:00:00 2001 From: Mattias Carlsson Date: Wed, 4 Mar 2026 12:31:48 +0100 Subject: [PATCH 6/9] =?UTF-8?q?chore(types):=20align=20FieldConfigBase=20w?= =?UTF-8?q?ith=20spec=20=E2=80=94=20shared=20placeholder/className,=20neve?= =?UTF-8?q?r=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move placeholder and className up to FieldConfigBase so both select and textarea variants share them. Add rows?: never on SelectFieldConfig and options?: never on TextAreaFieldConfig as mutual exclusion guards. --- src/lib/converter-configs.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/lib/converter-configs.ts b/src/lib/converter-configs.ts index 344ef74..a3f07b0 100644 --- a/src/lib/converter-configs.ts +++ b/src/lib/converter-configs.ts @@ -32,26 +32,28 @@ interface FieldConfigBase { name: string & keyof T /** Label text (static) */ label: string + /** Static or mode-dependent placeholder */ + placeholder?: string | ((mode: ConverterMode) => 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 + /** Static or mode-dependent className */ + className?: string | ((mode: ConverterMode) => string | undefined) } export interface SelectFieldConfig> extends FieldConfigBase { type: 'select' /** Options for select fields; 'encodings' resolves to the encoding list at render time */ options?: SelectOption[] | 'encodings' + rows?: never } export interface TextAreaFieldConfig> extends FieldConfigBase { type: 'textarea' /** Textarea row count */ rows?: number - /** Static or mode-dependent placeholder */ - placeholder?: string | ((mode: ConverterMode) => string) - /** Static or mode-dependent className */ - className?: string | ((mode: ConverterMode) => string | undefined) + options?: never } export type FieldConfig> = SelectFieldConfig | TextAreaFieldConfig From 568ebb868e495b187f2271a73748e25f0ce151e9 Mon Sep 17 00:00:00 2001 From: Mattias Carlsson Date: Wed, 4 Mar 2026 15:39:48 +0100 Subject: [PATCH 7/9] fix: tighten field config and validation schema types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move placeholder/className from FieldConfigBase to TextAreaFieldConfig (selects never use these — adding them was silently ignored) - Add placeholder?: never and className?: never to SelectFieldConfig for compile-time enforcement - Narrow conditionalInputValidation mode param from string to literal union - Use shared modeSchema in urlEncoderSchema instead of inline z.enum --- src/lib/converter-configs.ts | 10 ++++++---- src/lib/validation-schemas.ts | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/lib/converter-configs.ts b/src/lib/converter-configs.ts index a3f07b0..89e58c0 100644 --- a/src/lib/converter-configs.ts +++ b/src/lib/converter-configs.ts @@ -32,14 +32,10 @@ interface FieldConfigBase { name: string & keyof T /** Label text (static) */ label: string - /** Static or mode-dependent placeholder */ - placeholder?: string | ((mode: ConverterMode) => 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 - /** Static or mode-dependent className */ - className?: string | ((mode: ConverterMode) => string | undefined) } export interface SelectFieldConfig> extends FieldConfigBase { @@ -47,12 +43,18 @@ export interface SelectFieldConfig> extends FieldCon /** Options for select fields; 'encodings' resolves to the encoding list at render time */ options?: SelectOption[] | 'encodings' rows?: never + placeholder?: never + className?: never } export interface TextAreaFieldConfig> extends FieldConfigBase { type: 'textarea' /** Textarea row count */ rows?: number + /** Static or mode-dependent placeholder */ + placeholder?: string | ((mode: ConverterMode) => string) + /** Static or mode-dependent className */ + className?: string | ((mode: ConverterMode) => string | undefined) options?: never } 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(), From e2f61031290738862c6f3ac96ed983ab2cdb14e4 Mon Sep 17 00:00:00 2001 From: Mattias Carlsson Date: Wed, 4 Mar 2026 16:00:58 +0100 Subject: [PATCH 8/9] fix: address code review findings in type-hardening - Align StandardSchemaIssue.path with Standard Schema V1 spec (ReadonlyArray instead of (string | number)[]) - Pin ZodType Input parameter: ZodType prevents transform schemas from silently satisfying the constraint - Collapse EMPTY_INPUT/INPUT_TOO_LARGE switch cases to return error.message directly (avoids potential message duplication) --- src/lib/converter-configs.ts | 4 ++-- src/lib/errors.ts | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/lib/converter-configs.ts b/src/lib/converter-configs.ts index 89e58c0..986b621 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, @@ -66,7 +66,7 @@ export interface ConverterConfig { /** 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 */ diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 239e3b7..a7aaa0c 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -7,7 +7,7 @@ */ export interface StandardSchemaIssue { message: string - path?: (string | number)[] + path?: ReadonlyArray [key: string]: unknown } @@ -77,9 +77,8 @@ export function getErrorMessage(error: unknown): string { case ERROR_CODES.ENCODE_FAILED: return `Failed to encode data. ${error.message}` case ERROR_CODES.EMPTY_INPUT: - return `Input cannot be empty. ${error.message}` case ERROR_CODES.INPUT_TOO_LARGE: - return `Input is too large. ${error.message}` + return error.message default: { const _exhaustive: never = error.code void _exhaustive From c64aee8a7de532b628c8bbc92ced62bc0c1b4627 Mon Sep 17 00:00:00 2001 From: Mattias Carlsson Date: Wed, 4 Mar 2026 16:04:36 +0100 Subject: [PATCH 9/9] docs: add JSDoc to discriminated union guards and clarify comments - Add @internal JSDoc to never-typed guard properties explaining their role as discriminated-union sentinels - Add discriminant JSDoc to type: 'select' and type: 'textarea' - Clarify ConverterPage comment to describe property resolution intent --- src/components/form/ConverterPage.tsx | 2 +- src/lib/converter-configs.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/form/ConverterPage.tsx b/src/components/form/ConverterPage.tsx index 92627a5..3877665 100644 --- a/src/components/form/ConverterPage.tsx +++ b/src/components/form/ConverterPage.tsx @@ -73,7 +73,7 @@ export function ConverterPage({ ) } - // TypeScript narrows to TextAreaFieldConfig here + // Resolve textarea-specific dynamic properties (narrowed to TextAreaFieldConfig) const resolvedPlaceholder = typeof field.placeholder === 'function' ? field.placeholder(mode) diff --git a/src/lib/converter-configs.ts b/src/lib/converter-configs.ts index 986b621..d5bb015 100644 --- a/src/lib/converter-configs.ts +++ b/src/lib/converter-configs.ts @@ -39,15 +39,20 @@ interface FieldConfigBase { } 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 @@ -55,6 +60,7 @@ export interface TextAreaFieldConfig> extends FieldC placeholder?: string | ((mode: ConverterMode) => string) /** Static or mode-dependent className */ className?: string | ((mode: ConverterMode) => string | undefined) + /** @internal Discriminated-union guard — prevents cross-variant property assignment */ options?: never }