From 358c8749719b2255f48861bb3e81ff6448431747 Mon Sep 17 00:00:00 2001 From: Johannes Werner Date: Thu, 8 Oct 2026 19:15:20 +0200 Subject: [PATCH] refactor!: use type aliases and replace any with exact types - replace all interfaces with type aliases and rename interfaces.ts to types.ts - remove I prefix from generic type parameters - replace any with unknown for inputs and remove as any casts - fix types that did not match runtime values - enforce type aliases, no I prefix and no explicit any via eslint BREAKING CHANGE: types can no longer be extended via declaration merging. QuaireQuestion.navigationItemId is optional, selectOptions, rangeOption and inputOption can be null, QuaireNavigationItem.componentType can be null and QuaireNavigationItem.value is any instead of string. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- eslint.config.mjs | 6 +- examples/custom-component-types/MyQuaire.ts | 10 +- .../extending-data-definition/MyQuaire.ts | 22 ++-- .../linear-flow-skip-question.spec.ts | 2 +- src/index.ts | 2 +- src/quaire.ts | 109 +++++++++--------- src/{interfaces.ts => types.ts} | 74 ++++++------ src/utils.spec.ts | 2 +- src/utils.ts | 13 ++- vitest.config.mts | 2 +- 11 files changed, 130 insertions(+), 114 deletions(-) rename src/{interfaces.ts => types.ts} (51%) diff --git a/README.md b/README.md index 2e6254f..52b29d0 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ npm i --save quaire ## Define quaire data First you need to define the data (decision tree) based on the -[QuaireItem interface](https://github.com/devCrossNet/quaire/blob/main/src/interfaces.ts#L43). This can be static +[QuaireItem type](https://github.com/devCrossNet/quaire/blob/main/src/types.ts#L46). This can be static data in a JS/TS file, a JSON file that you load on demand or a dynamic JSON from a CMS or backend API. diff --git a/eslint.config.mjs b/eslint.config.mjs index 5f1cef7..1ed8a6b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -20,7 +20,11 @@ export default defineConfig( }, }, rules: { - '@typescript-eslint/no-explicit-any': 0, + '@typescript-eslint/consistent-type-definitions': ['error', 'type'], + '@typescript-eslint/naming-convention': [ + 'error', + { selector: 'typeLike', format: ['PascalCase'], custom: { regex: '^I[A-Z]', match: false } }, + ], '@typescript-eslint/no-require-imports': 0, 'no-async-promise-executor': 0, 'no-prototype-builtins': 0, diff --git a/examples/custom-component-types/MyQuaire.ts b/examples/custom-component-types/MyQuaire.ts index ae0638e..f374af6 100644 --- a/examples/custom-component-types/MyQuaire.ts +++ b/examples/custom-component-types/MyQuaire.ts @@ -32,7 +32,7 @@ export class MyQuaire extends Quaire { } // override default behaviour and apply new logic for MULTI_SELECT components - protected _validateSelectComponent(question: QuaireQuestion, currentAnswer: any[]) { + protected _validateSelectComponent(question: QuaireQuestion, currentAnswer: unknown[]) { if (question.componentType === MyComponentType.MULTI_SELECT) { const options = question.selectOptions.filter((o) => currentAnswer.includes(o.value)); @@ -46,7 +46,7 @@ export class MyQuaire extends Quaire { } // override default behaviour and apply new logic for MULTI_SELECT components - protected _getNextItemIdFromSelectComponents = (activeQuestion: QuaireQuestion, answer: any | any[]) => { + protected _getNextItemIdFromSelectComponents = (activeQuestion: QuaireQuestion, answer: unknown) => { const option = activeQuestion.selectOptions.find((o) => Array.isArray(answer) ? answer.includes(o.value) : o.value === answer, ); @@ -72,10 +72,12 @@ export class MyQuaire extends Quaire { } // override default behaviour and apply new logic for MULTI_SELECT components - protected _getNavigationValue(question: QuaireQuestion, answer: any) { + protected _getNavigationValue(question: QuaireQuestion, answer: unknown) { if (question.componentType === MyComponentType.MULTI_SELECT) { + const answers = answer as unknown[]; + return question.selectOptions - .filter((selectOption) => answer.includes(selectOption.value)) + .filter((selectOption) => answers.includes(selectOption.value)) .map((selectOption) => selectOption.label); } else { return super._getNavigationValue(question, answer); diff --git a/examples/extending-data-definition/MyQuaire.ts b/examples/extending-data-definition/MyQuaire.ts index 13b6f02..f3090b8 100644 --- a/examples/extending-data-definition/MyQuaire.ts +++ b/examples/extending-data-definition/MyQuaire.ts @@ -8,25 +8,25 @@ import { QuaireRangeItemOption, } from '../../src'; -export interface MyRangeItemOption extends QuaireRangeItemOption { +export type MyRangeItemOption = QuaireRangeItemOption & { divisor: number; unit: string; -} +}; -export interface MyItem extends QuaireItem { +export type MyItem = QuaireItem & { progress: number; rangeOption: MyRangeItemOption; -} +}; -export interface MyQuestion extends QuaireQuestion { +export type MyQuestion = QuaireQuestion & { progress: number; rangeOption: MyRangeItemOption; -} +}; -export interface MyNavigationItem extends QuaireNavigationItem { +export type MyNavigationItem = QuaireNavigationItem & { progress?: number; unit?: string; -} +}; export class MyQuaire extends Quaire { protected _getQuestionObject( @@ -35,7 +35,7 @@ export class MyQuaire extends Quaire { selectOptions: QuaireItemOption[], rangeOption: QuaireRangeItemOption, inputOption: QuaireInputItemOption, - defaultValue: any, + defaultValue: unknown, ): MyQuestion { return { ...super._getQuestionObject(item, dependsOnKeys, selectOptions, rangeOption, inputOption, defaultValue), @@ -47,8 +47,8 @@ export class MyQuaire extends Quaire { activeNavigationItem: MyNavigationItem, navigationItem: MyNavigationItem, question: MyQuestion, - answer: any, - isParent, + answer: unknown, + isParent: boolean, ): MyNavigationItem { return { ...super._getNavigationItemObject(activeNavigationItem, navigationItem, question, answer, isParent), diff --git a/examples/linear-flow-skip-question/linear-flow-skip-question.spec.ts b/examples/linear-flow-skip-question/linear-flow-skip-question.spec.ts index 02f6c70..c223b3f 100644 --- a/examples/linear-flow-skip-question/linear-flow-skip-question.spec.ts +++ b/examples/linear-flow-skip-question/linear-flow-skip-question.spec.ts @@ -2,7 +2,7 @@ import { NO_VALUE, Quaire } from '../../src'; import { items, navigationItems } from './data'; describe('linear-flow-skip-question', () => { - let Q; + let Q: Quaire; beforeEach(() => { Q = new Quaire({ items, navigationItems }); diff --git a/src/index.ts b/src/index.ts index 68af870..4278889 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ export * from './constants'; export * from './enums'; -export * from './interfaces'; +export * from './types'; export * from './quaire'; diff --git a/src/quaire.ts b/src/quaire.ts index 584089b..45ed714 100644 --- a/src/quaire.ts +++ b/src/quaire.ts @@ -8,18 +8,19 @@ import { QuaireOptions, QuaireQuestion, QuaireRangeItemOption, -} from './interfaces'; +} from './types'; import { QuaireComponentType, QuaireValidationError } from './enums'; import { getByPath, hasAnswer } from './utils'; export class Quaire< - IItem extends QuaireItem = QuaireItem, - IQuestion extends QuaireQuestion = QuaireQuestion, - INavigationItem extends QuaireNavigationItem = QuaireNavigationItem, -> implements QuaireBase { + Item extends QuaireItem = QuaireItem, + Question extends QuaireQuestion = QuaireQuestion, + NavigationItem extends QuaireNavigationItem = QuaireNavigationItem, +> implements QuaireBase { protected _activeItemId: number | null = null; - protected readonly _items: Array; - protected readonly _navigationItems: Array; + protected readonly _items: Array; + protected readonly _navigationItems: Array; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- answers can have any shape, consumers read them directly protected readonly _result: Record = {}; protected readonly _validationErrors: Record = {}; protected readonly _selectComponentTypes: Array = [QuaireComponentType.SINGLE_SELECT]; @@ -30,7 +31,7 @@ export class Quaire< QuaireComponentType.INPUT, ]; - constructor({ items, navigationItems, result }: QuaireOptions) { + constructor({ items, navigationItems, result }: QuaireOptions) { this._items = items; this._navigationItems = navigationItems || []; @@ -82,13 +83,13 @@ export class Quaire< } protected _getQuestionObject( - item: IItem, + item: Item, dependsOnKeys: string[], selectOptions: QuaireItemOption[] | null, rangeOption: QuaireRangeItemOption | null, inputOption: QuaireInputItemOption | null, - defaultValue: any, - ): IQuestion { + defaultValue: unknown, + ): Question { const value = this._getResultByValueProperty(item.resultProperty); let isValid = true; @@ -97,7 +98,7 @@ export class Quaire< isValid = false; } - return { + const question: QuaireQuestion = { id: item.id, navigationItemId: item.navigationItemId, question: item.question, @@ -122,10 +123,12 @@ export class Quaire< isValid, nextItemId: item.nextItemId, valueHasChanged: false, - } as any; + }; + + return question as Question; } - protected _getDependencyPath(item: IItem) { + protected _getDependencyPath(item: Item) { const path: Array = []; item.dependsOnResultProperties.forEach((resultProperty) => { @@ -146,17 +149,17 @@ export class Quaire< return item ? this._getQuestionFromItem(item) : null; } - protected _getQuestionFromItem(item: IItem) { + protected _getQuestionFromItem(item: Item) { let selectOptions: Array | null; let rangeOption: QuaireRangeItemOption | null; let inputOption: QuaireInputItemOption | null; - let defaultValue: any; + let defaultValue: unknown; if (item.dependsOnResultProperties.length > 0) { const path = this._getDependencyPath(item); - selectOptions = getByPath(item.selectOptions, path, null); - rangeOption = getByPath(item.rangeOption, path, null); - inputOption = getByPath(item.inputOption, path, null); + selectOptions = getByPath | null>(item.selectOptions, path, null); + rangeOption = getByPath(item.rangeOption, path, null); + inputOption = getByPath(item.inputOption, path, null); defaultValue = getByPath(item.defaultValue, path, null); } else { selectOptions = item.selectOptions ? (item.selectOptions as Array) : null; @@ -196,7 +199,7 @@ export class Quaire< return navigationItem || null; } - protected _validateSelectComponent(question: IQuestion, currentAnswer: any) { + protected _validateSelectComponent(question: Question, currentAnswer: unknown) { const option = question.selectOptions && question.selectOptions.find((o) => o.value === currentAnswer); if (question.required && !option) { @@ -205,7 +208,7 @@ export class Quaire< } } - protected _validateRangeComponent(question: IQuestion, activeQuestion: IQuestion | null) { + protected _validateRangeComponent(question: Question, activeQuestion: Question | null) { const isActiveQuestionADependency = !!question.dependsOnQuestions.find( (dq) => dq.resultProperty === activeQuestion?.resultProperty, ); @@ -218,9 +221,9 @@ export class Quaire< protected _validateGenericComponent( isQuestionInCurrentFlow: boolean, - question: IQuestion, - activeQuestion: IQuestion | null, - currentAnswer: any, + question: Question, + activeQuestion: Question | null, + currentAnswer: unknown, ) { if ( isQuestionInCurrentFlow && @@ -232,7 +235,7 @@ export class Quaire< } } - protected _validate(activeQuestion: IQuestion | null) { + protected _validate(activeQuestion: Question | null) { this._items.forEach((item) => { const question = this._getQuestionFromItem(item); const currentAnswer = this._getResultByValueProperty(question.resultProperty); @@ -303,22 +306,22 @@ export class Quaire< return null; } - protected _getNextItemIdFromSelectComponents = (activeQuestion: IQuestion | null, answer: any) => { + protected _getNextItemIdFromSelectComponents = (activeQuestion: Question | null, answer: unknown) => { const option = activeQuestion?.selectOptions?.find((o) => o.value === answer); return option?.nextItemId; }; // eslint-disable-next-line - protected _getNextItemIdFromRangeComponents = (activeQuestion: IQuestion | null, answer: any) => { + protected _getNextItemIdFromRangeComponents = (activeQuestion: Question | null, answer: unknown) => { return activeQuestion?.rangeOption?.nextItemId; }; // eslint-disable-next-line - protected _getNextItemIdFromInputComponents = (activeQuestion: IQuestion | null, answer: any) => { + protected _getNextItemIdFromInputComponents = (activeQuestion: Question | null, answer: unknown) => { return activeQuestion?.inputOption?.nextItemId; }; - public saveAnswer(answer: any) { + public saveAnswer(answer: unknown) { const activeQuestion = this.getActiveQuestion(); if (activeQuestion) { @@ -350,8 +353,8 @@ export class Quaire< } } - protected _getNavigationValue(question: IQuestion | null, answer: any): any { - let value: string | null | undefined; + protected _getNavigationValue(question: Question | null, answer: unknown): unknown { + let value: unknown; if (question && this._selectComponentTypes.includes(question.componentType)) { value = question?.selectOptions?.find((selectOption) => selectOption.value === answer)?.label; @@ -363,16 +366,16 @@ export class Quaire< } protected _getNavigationItemObject( - activeNavigationItem: INavigationItem | null, - navigationItem: INavigationItem, - question: IQuestion, - answer: any, + activeNavigationItem: NavigationItem | null, + navigationItem: NavigationItem, + question: Question, + answer: unknown, isParent: boolean, - ): INavigationItem { + ): NavigationItem { const active = activeNavigationItem?.id === navigationItem.id; const isValid = question.isValid; const hasValue = Boolean(answer); - let value: any = null; + let value: unknown = null; if (answer === NO_VALUE) { value = NO_VALUE; @@ -380,7 +383,7 @@ export class Quaire< value = this._getNavigationValue(question, answer); } - const item: any = { + const item: QuaireNavigationItem = { id: navigationItem.id, name: navigationItem.name, value, @@ -395,12 +398,12 @@ export class Quaire< item.subNavigation = []; } - return item; + return item as NavigationItem; } protected _getNavigationItem( - activeNavigationItem: INavigationItem | null, - navigationItem: INavigationItem, + activeNavigationItem: NavigationItem | null, + navigationItem: NavigationItem, isParent = true, ) { const question = this._getQuestionByNavigationItemId(navigationItem.id); @@ -415,8 +418,8 @@ export class Quaire< } // used for parent navigation items without an own question, the values are derived from the children - protected _getParentNavigationItemObject(navigationItem: INavigationItem): INavigationItem { - const item: any = { + protected _getParentNavigationItemObject(navigationItem: NavigationItem): NavigationItem { + const item: QuaireNavigationItem = { id: navigationItem.id, name: navigationItem.name, value: null, @@ -428,13 +431,13 @@ export class Quaire< subNavigation: [], }; - return item; + return item as NavigationItem; } protected _addNavigationItem( - navigationItems: { [key: string]: INavigationItem }, - activeNavigationItem: INavigationItem | null, - navigationItem: INavigationItem, + navigationItems: { [key: string]: NavigationItem }, + activeNavigationItem: NavigationItem | null, + navigationItem: NavigationItem, parentNavigationItemIds: Set, ) { const newNavigationItem = this._getNavigationItem(activeNavigationItem, navigationItem); @@ -448,9 +451,9 @@ export class Quaire< } protected _addChildNavigationItem( - navigationItems: { [key: string]: INavigationItem }, - activeNavigationItem: INavigationItem | null, - navigationItem: INavigationItem, + navigationItems: { [key: string]: NavigationItem }, + activeNavigationItem: NavigationItem | null, + navigationItem: NavigationItem, ) { const subNavigationItem = this._getNavigationItem(activeNavigationItem, navigationItem, false); @@ -462,8 +465,8 @@ export class Quaire< } } - public getNavigation = (): INavigationItem[] => { - const navigationItems: { [key: string]: INavigationItem } = {}; + public getNavigation = (): NavigationItem[] => { + const navigationItems: { [key: string]: NavigationItem } = {}; const parentNavigationItemIds = new Set(); const activeNavigationItem = this._getActiveQuestionNavigationItem(); @@ -478,7 +481,7 @@ export class Quaire< }); parentNavigationItemIds.forEach((id) => { - const subNavigation = navigationItems[id].subNavigation as INavigationItem[]; + const subNavigation = navigationItems[id].subNavigation as NavigationItem[]; if (subNavigation.length === 0) { delete navigationItems[id]; diff --git a/src/interfaces.ts b/src/types.ts similarity index 51% rename from src/interfaces.ts rename to src/types.ts index c11e89e..003eb7b 100644 --- a/src/interfaces.ts +++ b/src/types.ts @@ -1,49 +1,50 @@ import { QuaireComponentType, QuaireValidationError } from './enums'; -export interface QuaireBase< - IQuestion extends QuaireQuestion = QuaireQuestion, - INavigationItem extends QuaireNavigationItem = QuaireNavigationItem, -> { - saveAnswer(answer: any): void; - getActiveQuestion(): IQuestion | null; +export type QuaireBase< + Question extends QuaireQuestion = QuaireQuestion, + NavigationItem extends QuaireNavigationItem = QuaireNavigationItem, +> = { + saveAnswer(answer: unknown): void; + getActiveQuestion(): Question | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- answers can have any shape, consumers read them directly getResult(): Record; getValidationErrors(): Record; setActiveQuestionByNavigationItemId(navigationItemId: number): void; setActiveQuestionByQuestionId(questionId: number): void; isValid(): boolean; - getNavigation(): Array; -} + getNavigation(): Array; +}; -export interface QuaireOptions< - IItem extends QuaireItem = QuaireItem, - INavigationItem extends QuaireNavigationItem = QuaireNavigationItem, -> { - items: Array; - navigationItems?: Array; - result?: Record; -} +export type QuaireOptions< + Item extends QuaireItem = QuaireItem, + NavigationItem extends QuaireNavigationItem = QuaireNavigationItem, +> = { + items: Array; + navigationItems?: Array; + result?: Record; +}; -export interface QuaireItemOption { +export type QuaireItemOption = { [key: string]: unknown | QuaireItemOption; label?: string; value?: unknown; nextItemId?: number; -} +}; -export interface QuaireRangeItemOption { +export type QuaireRangeItemOption = { [key: string]: unknown | QuaireRangeItemOption; range?: Array; nextItemId?: number; -} +}; -export interface QuaireInputItemOption { +export type QuaireInputItemOption = { [key: string]: unknown | QuaireInputItemOption; type?: string; // HTML input type e.g. text, number, date, etc. placeholder?: string; nextItemId?: number; -} +}; -export interface QuaireItem { +export type QuaireItem = { id: number; question: string; description: string; @@ -55,38 +56,41 @@ export interface QuaireItem { selectOptions?: Array | QuaireItemOption; rangeOption?: QuaireRangeItemOption; inputOption?: QuaireInputItemOption; - defaultValue?: any; + defaultValue?: unknown; nextItemId?: number; -} +}; -export interface QuaireNavigationItem { +export type QuaireNavigationItem = { id: number; name: string; - value?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- answers can have any shape, consumers read them directly + value?: any; icon?: string; parentId?: number | null; active?: boolean; isValid?: boolean; hasValue?: boolean; subNavigation?: Array; - componentType?: QuaireComponentType | string; -} + componentType?: QuaireComponentType | string | null; +}; -export interface QuaireQuestion { +export type QuaireQuestion = { id: number; - navigationItemId: number; + navigationItemId?: number; question: string; description: string; required: boolean; resultProperty: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- answers can have any shape, consumers read them directly value: any; valueHasChanged?: boolean; componentType: QuaireComponentType | string; isValid: boolean; dependsOnQuestions: Array; - selectOptions?: Array; - rangeOption?: QuaireRangeItemOption; - inputOption?: QuaireInputItemOption; + selectOptions?: Array | null; + rangeOption?: QuaireRangeItemOption | null; + inputOption?: QuaireInputItemOption | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- answers can have any shape, consumers read them directly defaultValue?: any; nextItemId?: number; -} +}; diff --git a/src/utils.spec.ts b/src/utils.spec.ts index e49bdec..3be29af 100644 --- a/src/utils.spec.ts +++ b/src/utils.spec.ts @@ -4,7 +4,7 @@ describe('getByPath', () => { const object = { a: { b: [{ c: 'value' }] } }; test('should return the value at the path', () => { - expect(getByPath(object, ['a', 'b', '0', 'c'])).toBe('value'); + expect(getByPath(object, ['a', 'b', '0', 'c'], null)).toBe('value'); }); test('should return the default value for a missing path', () => { diff --git a/src/utils.ts b/src/utils.ts index d5d5ee1..f1e64f6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,13 +1,16 @@ -export const isNil = (value: any) => value === null || value === undefined; +export const isNil = (value: unknown): value is null | undefined => value === null || value === undefined; -export const hasAnswer = (answer: any) => (Array.isArray(answer) ? answer.length > 0 : isNil(answer) === false); +export const hasAnswer = (answer: unknown) => (Array.isArray(answer) ? answer.length > 0 : isNil(answer) === false); -export const getByPath = (object: any, path: Array, defaultValue: any = undefined) => { +export const getByPath = (object: unknown, path: Array, defaultValue: T): T => { if (path.length === 0) { return defaultValue; } - const value = path.reduce((current, key) => (isNil(current) ? undefined : current[key]), object); + const value = path.reduce( + (current, key) => (isNil(current) ? undefined : (current as Record)[key]), + object, + ); - return value === undefined ? defaultValue : value; + return value === undefined ? defaultValue : (value as T); }; diff --git a/vitest.config.mts b/vitest.config.mts index 2b836ee..f6d6cc3 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -7,7 +7,7 @@ export default defineConfig({ coverage: { provider: 'v8', include: ['src/**/*.ts', 'examples/**/*.ts'], - exclude: ['**/*.{test,spec}.ts', 'src/interfaces.ts'], + exclude: ['**/*.{test,spec}.ts', 'src/types.ts'], reportsDirectory: './coverage', reporter: ['html', 'text', 'lcov', 'json'], thresholds: {