From dba43f83d4e8a4514d6118f22afb3db5d2af2b4b Mon Sep 17 00:00:00 2001 From: Aliaksandr Samuseu Date: Thu, 17 Sep 2026 16:47:55 +0200 Subject: [PATCH 1/6] EPBDS-16211 E1 wave 1: 8 leaf utils to strict TypeScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type-only. classNames, codec, constants, lodash-lite, media, selectors, time, validators — the files with no intra-utils dependencies. 1072 -> 1160 lines. 20 explicit `any`, 18 of them in lodash-lite, which is a deliberately polymorphic lodash reimplementation; the rest of the wave needed two, both in codec where the honest type is JSON.parse's own `any`. ALSO IN THIS COMMIT, because the renames require it: 38 relative imports inside src/core/utils lose their explicit `.js` extension. Jest and webpack resolve `'./codec.js'` literally, so a .js -> .ts rename breaks it, and tsc cannot warn because checkJs is false — it fails at test and build time instead. The extension style existed in exactly two places in the repo: these 38 specifiers, and 17 in src/demo/examples/manifest.js which point at files that stay .js and are untouched. Against 824 extensionless relative imports elsewhere, utils was the anomaly, not the convention. ONE CODE MOVE, not a type change: lodash-lite's `capitalize` declaration now sits above the `export { … }` block instead of below it. Reason: for .js, eslint-config-react-app sets no-use-before-define to "off"; for .ts it enables the @typescript-eslint variant at "warn", so the pre-existing pattern started failing the zero-warning gate purely because the extension changed. Rather than weaken the rule for TypeScript I moved the one declaration that triggers it, and proved the move is inert: Babel's output for the file is byte-identical before and after (12047 bytes both). Verified: typecheck clean, lint:js clean, 26 utils suites / 565 tests green, full suite 2530 green, build-lib green, static/all.css sha256 unchanged. --- src/core/utils/_envs.js | 2 +- src/core/utils/array.js | 4 +- .../utils/{classNames.js => classNames.ts} | 30 +- src/core/utils/{codec.js => codec.ts} | 18 +- src/core/utils/components.js | 2 +- src/core/utils/{constants.js => constants.ts} | 15 +- src/core/utils/definitions.js | 4 +- src/core/utils/function.js | 8 +- src/core/utils/index.js | 22 +- .../utils/{lodash-lite.js => lodash-lite.ts} | 257 +++++++++++------- src/core/utils/{media.js => media.ts} | 11 +- src/core/utils/number.js | 6 +- src/core/utils/object.js | 4 +- src/core/utils/{selectors.js => selectors.ts} | 0 src/core/utils/storage.js | 12 +- src/core/utils/string.js | 2 +- src/core/utils/{time.js => time.ts} | 50 +++- src/core/utils/utility.js | 10 +- .../utils/{validators.js => validators.ts} | 6 +- 19 files changed, 276 insertions(+), 187 deletions(-) rename src/core/utils/{classNames.js => classNames.ts} (56%) rename src/core/utils/{codec.js => codec.ts} (60%) rename src/core/utils/{constants.js => constants.ts} (95%) rename src/core/utils/{lodash-lite.js => lodash-lite.ts} (59%) rename src/core/utils/{media.js => media.ts} (67%) rename src/core/utils/{selectors.js => selectors.ts} (100%) rename src/core/utils/{time.js => time.ts} (61%) rename src/core/utils/{validators.js => validators.ts} (76%) diff --git a/src/core/utils/_envs.js b/src/core/utils/_envs.js index 23e1f510..1004a06c 100644 --- a/src/core/utils/_envs.js +++ b/src/core/utils/_envs.js @@ -1,4 +1,4 @@ -import { LANGUAGE } from './constants.js' +import { LANGUAGE } from './constants' /** * Environment Variables diff --git a/src/core/utils/array.js b/src/core/utils/array.js index 00c6771c..f68ebc8a 100644 --- a/src/core/utils/array.js +++ b/src/core/utils/array.js @@ -12,8 +12,8 @@ import { unionBy, unionWith, uniqWith, -} from './lodash-lite.js' -import { toLowerCaseAny } from './string.js' +} from './lodash-lite' +import { toLowerCaseAny } from './string' /** * ARRAY FUNCTIONS ============================================================= diff --git a/src/core/utils/classNames.js b/src/core/utils/classNames.ts similarity index 56% rename from src/core/utils/classNames.js rename to src/core/utils/classNames.ts index c764d6da..a558f8cb 100644 --- a/src/core/utils/classNames.js +++ b/src/core/utils/classNames.ts @@ -1,16 +1,22 @@ const hasOwn = {}.hasOwnProperty -function toVal (mix) { - let k - let y +/** A dictionary whose truthy keys become class names. Values may be anything — only truthiness is read. */ +export interface ClassDictionary { + [key: string]: unknown +} + +/** Anything `classNames()` accepts, including nested arrays. */ +export type ClassValue = string | number | boolean | null | undefined | ClassDictionary | ClassValue[] + +function toVal (mix: ClassValue): string { let str = '' if (typeof mix === 'string' || typeof mix === 'number') { str += mix } else if (typeof mix === 'object' && mix != null) { if (Array.isArray(mix)) { - for (k = 0; k < mix.length; k++) { + for (let k = 0; k < mix.length; k++) { if (mix[k]) { - y = toVal(mix[k]) + const y = toVal(mix[k]) if (y) { if (str) str += ' ' str += y @@ -18,7 +24,7 @@ function toVal (mix) { } } } else { - for (k in mix) { + for (const k in mix) { if (hasOwn.call(mix, k) && mix[k]) { if (str) str += ' ' str += k @@ -31,16 +37,14 @@ function toVal (mix) { /** * Join class names (subset of the `classnames` package API). - * @param {...*} args - * @returns {string} */ -export default function classNames () { +export default function classNames (...args: ClassValue[]): string { let i = 0 - let tmp - let x + let tmp: ClassValue + let x: string let str = '' - while (i < arguments.length) { - tmp = arguments[i++] + while (i < args.length) { + tmp = args[i++] if (tmp) { x = toVal(tmp) if (x) { diff --git a/src/core/utils/codec.js b/src/core/utils/codec.ts similarity index 60% rename from src/core/utils/codec.js rename to src/core/utils/codec.ts index 507532cd..33a01cad 100644 --- a/src/core/utils/codec.js +++ b/src/core/utils/codec.ts @@ -1,3 +1,6 @@ +/** Replacer accepted by `toJSON`, matching `JSON.stringify`'s second argument. */ +type JSONReplacer = (this: unknown, key: string, value: unknown) => unknown + /** * Converts given value to a JSON string if necessary. * Circular references are replaced with the string "[Circular]". @@ -6,7 +9,10 @@ * @param {*} args - additional options (replacer, space) * @return {string} */ -export function toJSON (data, ...args) { +export function toJSON ( + data: unknown, + ...args: [replacer?: JSONReplacer | null, space?: string | number] +): any { if (typeof data !== 'object') return data const [replacer, space] = args return JSON.stringify(data, withCircularGuard(replacer), space) @@ -18,17 +24,17 @@ export function toJSON (data, ...args) { * @param {string} data - the string to be parsed * @return {Object|Null} - a JavaScript object if parsed successfully, null if not */ -export function fromJSON (data) { +export function fromJSON (data: unknown): any { try { - return JSON.parse(data) + return JSON.parse(data as string) } catch (e) { return data } } -function withCircularGuard (replacer) { - const seen = new WeakSet() - return function (key, value) { +function withCircularGuard (replacer: JSONReplacer | null | undefined): JSONReplacer { + const seen = new WeakSet() + return function (this: unknown, key: string, value: unknown) { if (typeof value === 'object' && value !== null) { if (seen.has(value)) return '[Circular]' seen.add(value) diff --git a/src/core/utils/components.js b/src/core/utils/components.js index 38539a44..1280590e 100644 --- a/src/core/utils/components.js +++ b/src/core/utils/components.js @@ -1 +1 @@ -export { default as cn } from './classNames.js' +export { default as cn } from './classNames' diff --git a/src/core/utils/constants.js b/src/core/utils/constants.ts similarity index 95% rename from src/core/utils/constants.js rename to src/core/utils/constants.ts index ce35496b..61da839e 100644 --- a/src/core/utils/constants.js +++ b/src/core/utils/constants.ts @@ -158,7 +158,7 @@ export const KEY = { X: 88, Y: 89, Z: 90, -} +} as const /* Distance */ export const ONE_MM = 1 @@ -263,16 +263,19 @@ export const LANGUAGE = { YIDDISH: {_: 'yi', lang: 'ייִדיש', 'en': 'Yiddish'}, ARABIC: {_: 'ar', lang: 'العَرَبِيَّة‎', 'en': 'Arabic'}, AFRIKAANS: {_: 'af', lang: 'Afrikaans', 'en': 'Afrikaans'}, -} +} as const /** * Object mapping of language to their code + * @note: every value is replaced with its `_` code by the loop below; the spread of `LANGUAGE` + * exists only so IDEs suggest the keys, and is never observable with its object values. */ +type LanguageCode = {[K in keyof typeof LANGUAGE]: (typeof LANGUAGE)[K]['_']} export const l = { ...LANGUAGE // enable IDE suggestion -} +} as unknown as LanguageCode for (const key in LANGUAGE) { - l[key] = LANGUAGE[key]._ + (l as unknown as Record)[key] = LANGUAGE[key as keyof typeof LANGUAGE]._ } /** @@ -303,11 +306,11 @@ export const LANGUAGE_LEVEL = { _: 5, [l.ENGLISH]: 'Native', }, -} +} as const /* Mappings */ export const SORT_ORDER = { 0: 'sort', 1: 'asc', [-1]: 'desc', -} +} as const diff --git a/src/core/utils/definitions.js b/src/core/utils/definitions.js index 53738beb..4dd179dd 100644 --- a/src/core/utils/definitions.js +++ b/src/core/utils/definitions.js @@ -1,5 +1,5 @@ -import { Active } from './_envs.js' -import { LANGUAGE } from './constants.js' +import { Active } from './_envs' +import { LANGUAGE } from './constants' const hasOwn = (object, key) => Object.prototype.hasOwnProperty.call(object, key) diff --git a/src/core/utils/function.js b/src/core/utils/function.js index b59265b7..b543f2db 100644 --- a/src/core/utils/function.js +++ b/src/core/utils/function.js @@ -1,7 +1,7 @@ -import { throttle as _throttle } from './lodash-lite.js' -import { __DEV__ } from './_envs.js' -import { isInListAny } from './array.js' -import { TIME_DURATION_INSTANT } from './constants.js' +import { throttle as _throttle } from './lodash-lite' +import { __DEV__ } from './_envs' +import { isInListAny } from './array' +import { TIME_DURATION_INSTANT } from './constants' /** * FUNCTION HELPERS ============================================================ diff --git a/src/core/utils/index.js b/src/core/utils/index.js index 5b9cca47..2fff6fe9 100644 --- a/src/core/utils/index.js +++ b/src/core/utils/index.js @@ -1,11 +1,11 @@ -export * from './_envs.js' -export * from './constants.js' -export * from './definitions.js' -export * from './array.js' -export * from './codec.js' -export * from './function.js' -export * from './number.js' -export * from './object.js' -export * from './storage.js' -export * from './string.js' -export * from './utility.js' +export * from './_envs' +export * from './constants' +export * from './definitions' +export * from './array' +export * from './codec' +export * from './function' +export * from './number' +export * from './object' +export * from './storage' +export * from './string' +export * from './utility' diff --git a/src/core/utils/lodash-lite.js b/src/core/utils/lodash-lite.ts similarity index 59% rename from src/core/utils/lodash-lite.js rename to src/core/utils/lodash-lite.ts index c58e53cb..61cb2c46 100644 --- a/src/core/utils/lodash-lite.js +++ b/src/core/utils/lodash-lite.ts @@ -1,32 +1,73 @@ // A tiny subset of lodash we rely on, implemented locally to avoid shipping lodash-es. // Intentionally limited API surface: only what this repo imports. - -function isObjectLike(value) { +// +// @Note on the types (§9.6-E1): these helpers are deliberately polymorphic — they take whatever a +// caller hands them and guard at runtime. The types below stay LOOSE on purpose: `unknown` for the +// values this module inspects, element generics only where the runtime genuinely passes a value +// through, and no tightening of what any function accepts. +// +// Every `any` here is deliberate and confined to three places, none of which is a value this module +// hands back to a caller: +// 1. parameters of USER callbacks (iteratee, comparator, customizer) — a contravariant `unknown` +// there would reject every caller that annotates its own callback, e.g. `(a: Row, b: Row) => …`; +// 2. the two relational comparisons in `min`/`max`, which are JS's own ordering over values the +// type system cannot order; +// 3. `throttle`'s saved `arguments`, which is replayed verbatim through `Function.apply`. + +/** Anything this module walks with a computed key once it knows the value is object-like. */ +type Dict = Record + +/** The resolved form of an iteratee shorthand. Callback parameters are `any` — see the note above. */ +type IterateeFn = (value: any, index?: any, collection?: any) => unknown + +/** + * The shorthands `toIteratee` understands: a function, a `[path, value]` pair, a source object to + * match, or any other value — which is treated as a property path, exactly as at runtime. The + * constituents are spelled out rather than collapsed to `unknown` so that a callback argument still + * gets its parameters contextually typed. + */ +type Iteratee = IterateeFn | PropertyKey | boolean | bigint | readonly unknown[] | object | null | undefined + +/** Custom equality callback, as taken by `uniqWith`/`unionWith`. Truthy means "same value". */ +type Comparator = (a: any, b: any) => unknown + +/** `setWith`'s customizer: returns the container to create for a missing path segment. */ +type SetWithCustomizer = (nsValue: any, key: PropertyKey, nsObject: any) => unknown + +/** `mergeWith`'s customizer: a non-`undefined` return wins over the default merge. */ +type MergeCustomizer = (dstValue: any, srcValue: any, key: string, dst: any, src: any) => unknown + +/** A function whose signature this module does not constrain (`throttle`'s subject). */ +type AnyFunction = (...args: any) => any + +type ThrottleOptions = { leading?: boolean, trailing?: boolean } + +function isObjectLike(value: unknown): value is object { return value != null && typeof value === 'object' } -function isObject(value) { +function isObject(value: unknown): value is object { return value != null && (typeof value === 'object' || typeof value === 'function') } -function sameValueZero(a, b) { +function sameValueZero(a: unknown, b: unknown): boolean { return a === b || (Number.isNaN(a) && Number.isNaN(b)) } -function enumerableKeys(value) { - return Object.keys(value).concat( +function enumerableKeys(value: object): Array { + return (Object.keys(value) as Array).concat( Object.getOwnPropertySymbols(value) .filter((key) => Object.prototype.propertyIsEnumerable.call(value, key)) ) } -function isPlainObject(value) { +function isPlainObject(value: unknown): value is Dict { if (!isObjectLike(value)) return false const proto = Object.getPrototypeOf(value) return proto === Object.prototype || proto === null } -function isEmpty(value) { +function isEmpty(value: unknown): boolean { if (value == null) return true if (typeof value === 'string') return value.length === 0 if (Array.isArray(value)) return value.length === 0 @@ -35,7 +76,7 @@ function isEmpty(value) { return false } -function toPath(path) { +function toPath(path: unknown): PropertyKey[] { if (Array.isArray(path)) return path.slice() if (path == null) return [] const str = String(path) @@ -51,16 +92,16 @@ function toPath(path) { // @Note: bracket indices become numbers here, unlike lodash which keeps every segment a // string. `toPath` is internal, and `setWith` relies on the number to create arrays. const re = /[^.[\]]+|\[(?:(-?\d+)|(["'])(.*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g - const out = [] + const out: PropertyKey[] = [] if (str.charCodeAt(0) === 46 /* . */) out.push('') - str.replace(re, (_, index, _q, quoted) => { + str.replace(re, (_: string, index: string | undefined, _q: string | undefined, quoted: string | undefined) => { out.push(index !== undefined ? Number(index) : (quoted !== undefined ? quoted : _)) return '' }) return out } -function get(object, path, defaultValue) { +function get(object: unknown, path: unknown, defaultValue?: unknown): unknown { const parts = toPath(path) // An empty path resolves to nothing, never to `object` itself. // Otherwise `get(data, '')` hands out the whole data object, and a config such as @@ -69,31 +110,31 @@ function get(object, path, defaultValue) { // key. We always return the fallback instead, so an empty path can never yield an object. // `setWith` and `unset` treat an empty path as "no path" too. if (parts.length === 0) return defaultValue - let cur = object + let cur: unknown = object for (const key of parts) { if (cur == null) return defaultValue - cur = cur[key] + cur = (cur as Dict)[key] } return cur === undefined ? defaultValue : cur } -function hasPath(object, path) { +function hasPath(object: unknown, path: unknown): boolean { const parts = toPath(path) if (parts.length === 0) return false - let cur = object + let cur: unknown = object for (const key of parts) { if (cur == null || !(key in Object(cur))) return false - cur = cur[key] + cur = (cur as Dict)[key] } return true } -function setWith(object, path, value, customizer) { +function setWith(object: T, path: unknown, value: unknown, customizer?: SetWithCustomizer): T { if (object == null) return object const parts = toPath(path) if (parts.length === 0) return object - let cur = object + let cur = object as unknown as Dict for (let i = 0; i < parts.length; i++) { const key = parts[i] if (key === '__proto__' || key === 'constructor' || key === 'prototype') return object @@ -111,12 +152,12 @@ function setWith(object, path, value, customizer) { next = created == null ? (typeof nextKey === 'number' ? [] : {}) : created cur[key] = next } - cur = next + cur = next as Dict } return object } -function unset(object, path) { +function unset(object: unknown, path: unknown): boolean { if (object == null) return false const parts = toPath(path) if (parts.length === 0) return false @@ -124,70 +165,74 @@ function unset(object, path) { const parent = parts.length === 1 ? object : get(object, parts.slice(0, -1)) if (parent == null) return false if (Object.prototype.hasOwnProperty.call(parent, last)) { - delete parent[last] + delete (parent as Dict)[last] return true } return false } -function cloneDeep(value, seen = new Map()) { +function cloneDeep(value: T, seen: Map = new Map()): T { if (!isObjectLike(value)) return value - if (seen.has(value)) return seen.get(value) + if (seen.has(value)) return seen.get(value) as T if (Array.isArray(value)) { const out = new Array(value.length) seen.set(value, out) for (let i = 0; i < value.length; i++) out[i] = cloneDeep(value[i], seen) - return out + return out as T } - if (value instanceof Date) return new Date(value.getTime()) - if (value instanceof RegExp) return new RegExp(value.source, value.flags) + if (value instanceof Date) return new Date(value.getTime()) as T + if (value instanceof RegExp) return new RegExp(value.source, value.flags) as T if (value instanceof Map) { const out = new Map() seen.set(value, out) for (const [k, v] of value.entries()) out.set(cloneDeep(k, seen), cloneDeep(v, seen)) - return out + return out as T } if (value instanceof Set) { const out = new Set() seen.set(value, out) for (const v of value.values()) out.add(cloneDeep(v, seen)) - return out + return out as T } if (isPlainObject(value)) { - const out = {} + const out: Dict = {} seen.set(value, out) for (const k of enumerableKeys(value)) out[k] = cloneDeep(value[k], seen) - return out + return out as T } // For class instances and other objects, keep reference as-is. return value } -function isEqual(a, b, seen = new Map()) { +function isEqual(a: unknown, b: unknown, seen: Map = new Map()): boolean { if (a === b) return true if (Number.isNaN(a) && Number.isNaN(b)) return true if (!isObjectLike(a) || !isObjectLike(b)) return false if (a.constructor !== b.constructor) return false + // From here on `a` and `b` share a constructor, which is what lets every `b as ...` below + // stand: whatever narrowed `a` describes `b` just as well. const seenKey = seen.get(a) if (seenKey && seenKey === b) return true seen.set(a, b) if (Array.isArray(a)) { - if (a.length !== b.length) return false - for (let i = 0; i < a.length; i++) if (!isEqual(a[i], b[i], seen)) return false + const bArray = b as unknown[] + if (a.length !== bArray.length) return false + for (let i = 0; i < a.length; i++) if (!isEqual(a[i], bArray[i], seen)) return false return true } - if (a instanceof Date) return sameValueZero(a.getTime(), b.getTime()) - if (a instanceof RegExp) return a.source === b.source && a.flags === b.flags + if (a instanceof Date) return sameValueZero(a.getTime(), (b as Date).getTime()) + if (a instanceof RegExp) return a.source === (b as RegExp).source && a.flags === (b as RegExp).flags if (a instanceof Number || a instanceof String || a instanceof Boolean) { - return sameValueZero(a.valueOf(), b.valueOf()) + return sameValueZero(a.valueOf(), (b as Number | String | Boolean).valueOf()) } - if (a instanceof Error) return a.name === b.name && a.message === b.message + if (a instanceof Error) return a.name === (b as Error).name && a.message === (b as Error).message if (a instanceof Map) { - if (a.size !== b.size) return false - const remaining = [...b.entries()] + const bMap = b as Map + if (a.size !== bMap.size) return false + const remaining = [...bMap.entries()] for (const [aKey, aValue] of a.entries()) { let match = -1 for (let i = 0; i < remaining.length; i++) { @@ -205,8 +250,9 @@ function isEqual(a, b, seen = new Map()) { return true } if (a instanceof Set) { - if (a.size !== b.size) return false - const remaining = [...b.values()] + const bSet = b as Set + if (a.size !== bSet.size) return false + const remaining = [...bSet.values()] for (const aValue of a.values()) { let match = -1 for (let i = 0; i < remaining.length; i++) { @@ -223,28 +269,29 @@ function isEqual(a, b, seen = new Map()) { return true } if (isPlainObject(a)) { + const bDict = b as Dict const aKeys = enumerableKeys(a) const bKeys = enumerableKeys(b) if (aKeys.length !== bKeys.length) return false for (const k of aKeys) { if (!Object.prototype.hasOwnProperty.call(b, k)) return false - if (!isEqual(a[k], b[k], seen)) return false + if (!isEqual(a[k], bDict[k], seen)) return false } return true } return false } -function property(path) { +function property(path: unknown): (obj: unknown) => unknown { return (obj) => get(obj, path) } -function matches(source) { +function matches(source: unknown): (object: unknown) => boolean { const snapshot = cloneDeep(source) return (object) => isMatch(object, snapshot) } -function matchesProperty(path, sourceValue) { +function matchesProperty(path: unknown, sourceValue: unknown): (object: unknown) => boolean { const snapshot = cloneDeep(sourceValue) return (object) => { const value = get(object, path) @@ -253,15 +300,15 @@ function matchesProperty(path, sourceValue) { } } -function toIteratee(value) { - if (typeof value === 'function') return value - if (value == null) return (item) => item +function toIteratee(value: unknown): IterateeFn { + if (typeof value === 'function') return value as IterateeFn + if (value == null) return (item: unknown) => item if (Array.isArray(value)) return matchesProperty(value[0], value[1]) if (isObjectLike(value)) return matches(value) return property(value) } -function isMatch(object, source) { +function isMatch(object: unknown, source: unknown): boolean { if (sameValueZero(object, source)) return true if (Array.isArray(source)) { if (!Array.isArray(object) || source.length > object.length) return false @@ -284,7 +331,7 @@ function isMatch(object, source) { for (const key of enumerableKeys(source)) { if (!(key in Object(object))) return false const sv = source[key] - const ov = object[key] + const ov = (object as Dict)[key] if (isObjectLike(sv)) { if (!isMatch(ov, sv)) return false } else if (!sameValueZero(ov, sv)) { @@ -294,7 +341,7 @@ function isMatch(object, source) { return true } -function some(collection, predicate) { +function some(collection: unknown, predicate?: Iteratee): boolean { if (collection == null) return false const pred = toIteratee(predicate) if (Array.isArray(collection)) { @@ -303,15 +350,16 @@ function some(collection, predicate) { } return false } - for (const key in collection) { - if (Object.prototype.hasOwnProperty.call(collection, key) && pred(collection[key], key, collection)) return true + const dict = collection as Dict + for (const key in dict) { + if (Object.prototype.hasOwnProperty.call(dict, key) && pred(dict[key], key, dict)) return true } return false } -function flatten(array) { +function flatten(array: ReadonlyArray | null | undefined): T[] { if (!Array.isArray(array)) return [] - const out = [] + const out: T[] = [] for (const item of array) { if (Array.isArray(item)) out.push(...item) else out.push(item) @@ -319,39 +367,43 @@ function flatten(array) { return out } -function min(array) { +function min(array: readonly T[] | null | undefined): T | undefined { if (!Array.isArray(array) || array.length === 0) return undefined - let m + let m: T | undefined for (const value of array) { if (value == null || Number.isNaN(value) || typeof value === 'symbol') continue - if (m === undefined || value < m) m = value + // `<` is JS's own relational comparison over values the type system cannot order + // (numbers, strings and dates all reach this line); the casts express that, and change + // nothing at runtime. + if (m === undefined || (value as any) < (m as any)) m = value } return m } -function max(array) { +function max(array: readonly T[] | null | undefined): T | undefined { if (!Array.isArray(array) || array.length === 0) return undefined - let m + let m: T | undefined for (const value of array) { if (value == null || Number.isNaN(value) || typeof value === 'symbol') continue - if (m === undefined || value > m) m = value + // See the note in `min` about the relational-operator casts. + if (m === undefined || (value as any) > (m as any)) m = value } return m } -function difference(array, values) { +function difference(array: readonly T[] | null | undefined, values?: unknown): T[] { if (!Array.isArray(array)) return [] const remove = new Set(Array.isArray(values) ? values : []) - const out = [] + const out: T[] = [] for (const value of array) if (!remove.has(value)) out.push(value) return out } -function intersection(...arrays) { +function intersection(...arrays: Array): T[] { if (arrays.length === 0 || arrays.some((array) => !Array.isArray(array))) return [] - const [first, ...rest] = arrays + const [first, ...rest] = arrays as Array const restSets = rest.map((a) => new Set(a)) - const out = [] + const out: T[] = [] const seen = new Set() for (const value of first) { if (!seen.has(value) && restSets.every((set) => set.has(value))) { @@ -362,8 +414,8 @@ function intersection(...arrays) { return out } -function union(...arrays) { - const out = [] +function union(...arrays: Array): T[] { + const out: T[] = [] const seen = new Set() for (const arr of arrays) { if (!Array.isArray(arr)) continue @@ -377,22 +429,22 @@ function union(...arrays) { return out } -function uniqWith(array, comparator) { +function uniqWith(array: readonly T[] | null | undefined, comparator?: Comparator): T[] { if (!Array.isArray(array)) return [] if (typeof comparator !== 'function') return union(array) - const out = [] + const out: T[] = [] for (const v of array) { if (!out.some((o) => comparator(o, v))) out.push(v) } return out } -function unionWith(...args) { +function unionWith(...args: unknown[]): unknown[] { const lastArg = args[args.length - 1] - const comparator = typeof lastArg === 'function' ? lastArg : null + const comparator = typeof lastArg === 'function' ? lastArg as Comparator : null const arrays = comparator ? args.slice(0, -1) : args - if (!comparator) return union(...arrays) - const out = [] + if (!comparator) return union(...arrays as Array) + const out: unknown[] = [] for (const arr of arrays) { if (!Array.isArray(arr)) continue for (const v of arr) { @@ -402,11 +454,11 @@ function unionWith(...args) { return out } -function unionBy(...args) { +function unionBy(...args: unknown[]): unknown[] { const lastArg = args[args.length - 1] const iteratee = Array.isArray(lastArg) ? undefined : args.pop() const it = toIteratee(iteratee) - const out = [] + const out: unknown[] = [] const seen = new Set() for (const arr of args) { if (!Array.isArray(arr)) continue @@ -421,32 +473,33 @@ function unionBy(...args) { return out } -function mergeWith(target, ...rest) { +function mergeWith(target: unknown, ...rest: unknown[]): Dict { target = target == null ? {} : Object(target) const customizer = rest[rest.length - 1] const sources = typeof customizer === 'function' ? rest.slice(0, -1) : rest - const cz = typeof customizer === 'function' ? customizer : null + const cz = typeof customizer === 'function' ? customizer as MergeCustomizer : null for (const src of sources) { - _mergeInto(target, src, cz) + _mergeInto(target as Dict, src, cz) } - return target + return target as Dict } -function merge(target, ...sources) { +function merge(target: unknown, ...sources: unknown[]): Dict { return mergeWith(target, ...sources) } -function _mergeInto(dst, src, customizer) { +function _mergeInto(dst: Dict, src: unknown, customizer: MergeCustomizer | null): void { if (!isObjectLike(src)) return + const source = src as Dict // Lodash merge includes inherited enumerable string keys and skips sparse-array holes. - for (const key in src) { + for (const key in source) { if (key === '__proto__') continue - const srcVal = src[key] + const srcVal = source[key] // lodash merge/mergeWith skips `undefined` source values if (srcVal === undefined) continue const dstVal = dst[key] if (customizer) { - const customized = customizer(dstVal, srcVal, key, dst, src) + const customized = customizer(dstVal, srcVal, key, dst, source) if (customized !== undefined) { dst[key] = customized continue @@ -457,25 +510,26 @@ function _mergeInto(dst, src, customizer) { // Previous concat() broke form-data merge: a sparse [, , , {}] from a child form was appended after // the master array instead of overlaying index 3, producing phantom rows. if (!Array.isArray(dstVal)) dst[key] = [] - _mergeInto(dst[key], srcVal, customizer) + _mergeInto(dst[key] as Dict, srcVal, customizer) } else if (isPlainObject(srcVal)) { if (!isPlainObject(dstVal)) dst[key] = {} - _mergeInto(dst[key], srcVal, customizer) + _mergeInto(dst[key] as Dict, srcVal, customizer) } else { dst[key] = srcVal } } } -function throttle(func, wait, options = {}) { +function throttle(func: AnyFunction, wait: number, options: ThrottleOptions = {}): AnyFunction { let lastCallTime = 0 - let timeoutId = null - let lastArgs - let lastThis + let timeoutId: ReturnType | null = null + // `arguments` of the last throttled call, kept verbatim for `func.apply`. + let lastArgs: any + let lastThis: unknown const leading = options.leading !== false const trailing = options.trailing !== false - function invoke(time) { + function invoke(time: number) { lastCallTime = time const args = lastArgs const self = lastThis @@ -483,14 +537,14 @@ function throttle(func, wait, options = {}) { return func.apply(self, args) } - function startTimer(remaining) { + function startTimer(remaining: number) { timeoutId = setTimeout(() => { timeoutId = null if (trailing && lastArgs) invoke(Date.now()) }, remaining) } - return function throttled() { + return function throttled(this: unknown) { const now = Date.now() if (!lastCallTime && leading === false) lastCallTime = now const remaining = wait - (now - lastCallTime) @@ -508,10 +562,16 @@ function throttle(func, wait, options = {}) { } } -function isNumber(value) { +function isNumber(value: unknown): boolean { return typeof value === 'number' || value instanceof Number } +function capitalize(string: unknown): string { + const str = String(string == null ? '' : string) + if (!str) return '' + return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase() +} + export { // core get, @@ -545,8 +605,3 @@ export { capitalize, } -function capitalize(string) { - string = String(string == null ? '' : string) - if (!string) return '' - return string.charAt(0).toUpperCase() + string.slice(1).toLowerCase() -} diff --git a/src/core/utils/media.js b/src/core/utils/media.ts similarity index 67% rename from src/core/utils/media.js rename to src/core/utils/media.ts index fa42f107..3ac7721b 100644 --- a/src/core/utils/media.js +++ b/src/core/utils/media.ts @@ -1,4 +1,3 @@ - /** * MEDIA FUNCTIONS ============================================================= * ============================================================================= @@ -13,11 +12,11 @@ * = sqrt(50/200)*10 * = 5 * - * @param {Number} res - resolution limit to compute width for - * @param {Number} width - original dimension - * @param {Number} height - original dimension - * @returns {Number} width - for given `res` + * @param res - resolution limit to compute width for + * @param width - original dimension + * @param height - original dimension + * @returns width - for given `res` */ -export function widthScaled (res, width, height) { +export function widthScaled (res: number, width: number, height: number): number { return Math.round(Math.sqrt(res / width / height) * width) } diff --git a/src/core/utils/number.js b/src/core/utils/number.js index 7e4d8deb..aaf8ca1f 100644 --- a/src/core/utils/number.js +++ b/src/core/utils/number.js @@ -1,5 +1,5 @@ -import { hasListValue } from './array.js' -import { isInString } from './string.js' +import { hasListValue } from './array' +import { isInString } from './string' /** * NUMBER FUNCTIONS ============================================================ @@ -25,7 +25,7 @@ import { isInString } from './string.js' * @param {*} val - The value to check. * @returns {boolean} - Returns true if value is a number, else false. */ -export { isNumber } from './lodash-lite.js' +export { isNumber } from './lodash-lite' /** * Returns true if the given variable is a number, diff --git a/src/core/utils/object.js b/src/core/utils/object.js index dd4f1171..808446cf 100644 --- a/src/core/utils/object.js +++ b/src/core/utils/object.js @@ -11,8 +11,8 @@ import { property, setWith, unset, -} from './lodash-lite.js' -import { isCollection } from './array.js' +} from './lodash-lite' +import { isCollection } from './array' /** * OBJECT FUNCTIONS ============================================================ diff --git a/src/core/utils/selectors.js b/src/core/utils/selectors.ts similarity index 100% rename from src/core/utils/selectors.js rename to src/core/utils/selectors.ts diff --git a/src/core/utils/storage.js b/src/core/utils/storage.js index 7e55e4c9..a6e836f5 100644 --- a/src/core/utils/storage.js +++ b/src/core/utils/storage.js @@ -1,9 +1,9 @@ -import { Active } from './_envs.js' -import { isList } from './array.js' -import { fromJSON, toJSON } from './codec.js' -import { ADD, DELETE, GET, SET } from './constants.js' -import { enumCheck } from './function.js' -import { update } from './object.js' +import { Active } from './_envs' +import { isList } from './array' +import { fromJSON, toJSON } from './codec' +import { ADD, DELETE, GET, SET } from './constants' +import { enumCheck } from './function' +import { update } from './object' /** * STORAGE FUNCTIONS =========================================================== diff --git a/src/core/utils/string.js b/src/core/utils/string.js index 2e3e6100..84c0362c 100644 --- a/src/core/utils/string.js +++ b/src/core/utils/string.js @@ -1,4 +1,4 @@ -import { capitalize, get } from './lodash-lite.js' +import { capitalize, get } from './lodash-lite' export const alphaNumPattern = /[^a-zA-Z0-9]/g export const alphaNumIdPattern = /[^a-zA-Z0-9_-]/g diff --git a/src/core/utils/time.js b/src/core/utils/time.ts similarity index 61% rename from src/core/utils/time.js rename to src/core/utils/time.ts index 5b569f02..d02ba823 100644 --- a/src/core/utils/time.js +++ b/src/core/utils/time.ts @@ -3,7 +3,14 @@ * ============================================================================= */ -const UNITS = [ +interface UnitDefinition { + unit: string + /** [singular, plural] */ + long: [string, string] + ms: number +} + +const UNITS: UnitDefinition[] = [ { unit: 'y', long: ['year', 'years'], ms: 31557600000 }, { unit: 'mo', long: ['month', 'months'], ms: 2629800000 }, { unit: 'w', long: ['week', 'weeks'], ms: 604800000 }, @@ -14,33 +21,45 @@ const UNITS = [ { unit: 'ms', long: ['millisecond', 'milliseconds'], ms: 1 }, ] +export interface FormatDurationOptions { + /** whether to use short unit forms (y/mo/w/d/h/m/s/ms) */ + shorten?: boolean + /** whether to round the smallest unit (default true) */ + round?: boolean + /** render at most N largest non-zero units */ + largest?: number | null + /** separator between units (default ', ') */ + delimiter?: string + /** separator between value and unit (default ' ') */ + spacer?: string + /** decimal separator for fractional values (default '.') */ + decimal?: string +} + /** * Convert Time Duration to User Friendly and Readable Format * - * @param {Number} milliseconds - duration in milliseconds to convert - * @param {Object} [options] - * @param {Boolean} [options.shorten] - whether to use short unit forms (y/mo/w/d/h/m/s/ms) - * @param {Boolean} [options.round=true] - whether to round the smallest unit - * @param {Number} [options.largest] - render at most N largest non-zero units - * @param {String} [options.delimiter=', '] - separator between units - * @param {String} [options.spacer=' '] - separator between value and unit - * @param {String} [options.decimal='.'] - decimal separator for fractional values - * @returns {String} - formatted time + * `milliseconds` is passed through `Number()`, so the public API also accepts + * numeric strings, `null` and `undefined` — anything non-finite formats as zero. + * + * @param milliseconds - duration in milliseconds to convert + * @param options + * @returns formatted time */ -export function formatDuration (milliseconds, { +export function formatDuration (milliseconds: unknown, { shorten = false, round = true, largest, delimiter = ', ', spacer = ' ', decimal = '.', -} = {}) { +}: FormatDurationOptions = {}): string { const duration = Number(milliseconds) if (!Number.isFinite(duration) || duration === 0) { return shorten ? `0${spacer}s` : `0${spacer}seconds` } let remaining = Math.abs(duration) - const parts = [] + const parts: string[] = [] for (let i = 0; i < UNITS.length; i++) { const def = UNITS[i] const isLast = i === UNITS.length - 1 @@ -65,6 +84,9 @@ export function formatDuration (milliseconds, { return sign + parts.join(delimiter) } -formatDuration.shortEnglish = function (milliseconds, options = {}) { +formatDuration.shortEnglish = function ( + milliseconds: unknown, + options: FormatDurationOptions | null | undefined = {}, +): string { return formatDuration(milliseconds, { ...options, shorten: true }) } diff --git a/src/core/utils/utility.js b/src/core/utils/utility.js index fa9ce6af..a0b2f3a1 100644 --- a/src/core/utils/utility.js +++ b/src/core/utils/utility.js @@ -1,8 +1,8 @@ -import { Active } from './_envs.js' -import { isInList, isList } from './array.js' -import { rad } from './number.js' -import { isObject } from './object.js' -import { isString, padStringLeft, randomString } from './string.js' +import { Active } from './_envs' +import { isInList, isList } from './array' +import { rad } from './number' +import { isObject } from './object' +import { isString, padStringLeft, randomString } from './string' /** * AD HOC FUNCTIONS ============================================================ diff --git a/src/core/utils/validators.js b/src/core/utils/validators.ts similarity index 76% rename from src/core/utils/validators.js rename to src/core/utils/validators.ts index 28845df7..39a76bfd 100644 --- a/src/core/utils/validators.js +++ b/src/core/utils/validators.ts @@ -5,11 +5,11 @@ // Pragmatic email check (aligned with common HTML5-style patterns) const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ -export function isEmail (value) { +export function isEmail (value: unknown): boolean { return typeof value === 'string' && value.length > 0 && EMAIL_RE.test(value) } -export function isLengthMax (value, max) { +export function isLengthMax (value: unknown, max: number): boolean { const s = value == null ? '' : String(value) return s.length <= max } @@ -17,6 +17,6 @@ export function isLengthMax (value, max) { // require_protocol: true — http:// or https://, non-whitespace remainder const URL_WITH_PROTOCOL_RE = /^https?:\/\/\S+$/i -export function isURLWithProtocol (value) { +export function isURLWithProtocol (value: unknown): boolean { return typeof value === 'string' && value.length > 0 && URL_WITH_PROTOCOL_RE.test(value) } From 6e4d6e17a29dae5689c7e126b8cf56d8120cab78 Mon Sep 17 00:00:00 2001 From: Aliaksandr Samuseu Date: Thu, 17 Sep 2026 16:56:39 +0200 Subject: [PATCH 2/6] EPBDS-16211 E1 wave 2: _envs, components, string to strict TypeScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type-only, zero `any` across all three. 719 -> 806 lines, 53 agent-minutes. _envs needed the most thought and none of it was about syntax: Active is a module-global that platform code attaches ad-hoc properties to at runtime (Active.Field, Active.renderField, Active.UIRender, Active.SERVICE), so it carries an index signature rather than a closed shape, and the env-dependent slots (Storage, WebSocket, client, log, history) are `unknown` rather than their DOM types — backends replace Active.Storage with an adapter that has init()/initSync(), which the DOM Storage interface does not have. Typing it as Storage would have been a lie that compiled. Three call sites were identified as needing attention when THEIR file converts, and deliberately left alone here: storage.ts will need to narrow Active.Storage; utility.ts's `Active.passwordCheck(password).score` needs a null check or an explicit non-null assertion, because on the backend passwordCheck is undefined and that line throwing is the current behaviour; and modules/variables/_envs.js assigns a passwordCheck that returns undefined, which is a behaviour question, not a type question. components.ts is a one-line re-export with NO callers anywhere — every real consumer of `cn` imports classNames directly. Converted as-is; flagged for a dead-code pass rather than deleted inside a type-only change. EXTENSIONED SPECIFIERS, ROUND TWO. Wave 1 stripped `.js` from imports inside src/core/utils, but only from `from '…'` forms in the modules themselves. The sweep missed jest.doMock / jest.requireActual / require() and it missed the __tests__ directory entirely, so converting string.js broke five tests in utility.id-collisions-and-history.test.js, which mocks '../string.js' by explicit path. Fixed, and the search was redone across all of src in every call form. What it found: the only other in-src hazard is Table.test.js's require.resolve('../Table.js'), which would have broken when E2 converts Table — defused here while it is a one-line change rather than a red CI run. Everything else pointing at a `.js` path is in src/style tests referencing scripts/ and postcss.config.js, which live outside src and are not migrating. --- src/core/components/__tests__/Table.test.js | 2 +- .../utility.id-collisions-and-history.test.js | 8 +- src/core/utils/_envs.js | 55 ------ src/core/utils/_envs.ts | 93 ++++++++++ .../utils/{components.js => components.ts} | 0 src/core/utils/{string.js => string.ts} | 169 +++++++++++------- 6 files changed, 207 insertions(+), 120 deletions(-) delete mode 100644 src/core/utils/_envs.js create mode 100644 src/core/utils/_envs.ts rename src/core/utils/{components.js => components.ts} (100%) rename src/core/utils/{string.js => string.ts} (75%) diff --git a/src/core/components/__tests__/Table.test.js b/src/core/components/__tests__/Table.test.js index 52648abc..eec06ce8 100644 --- a/src/core/components/__tests__/Table.test.js +++ b/src/core/components/__tests__/Table.test.js @@ -209,7 +209,7 @@ describe('Table', () => { // a re-introduction would otherwise only surface in a generated-page diff. // Matched as a module reference, not as the bare string: the file's own header // explains what it replaced, and a substring check would fail on the prose. - const source = require('fs').readFileSync(require.resolve('../Table.js'), 'utf8') + const source = require('fs').readFileSync(require.resolve('../Table'), 'utf8') expect(source).not.toMatch(/(?:from|require\(|import\(|jest\.mock\()\s*['"]semantic-ui-react/) }) diff --git a/src/core/utils/__tests__/utility.id-collisions-and-history.test.js b/src/core/utils/__tests__/utility.id-collisions-and-history.test.js index 0bf03013..7b42d7e3 100644 --- a/src/core/utils/__tests__/utility.id-collisions-and-history.test.js +++ b/src/core/utils/__tests__/utility.id-collisions-and-history.test.js @@ -1,6 +1,6 @@ describe('Id collision and history contracts', () => { afterEach(() => { - jest.dontMock('../string.js') + jest.dontMock('../string') jest.resetModules() }) @@ -8,14 +8,14 @@ describe('Id collision and history contracts', () => { jest.resetModules() const randomString = jest.fn() values.forEach(value => randomString.mockReturnValueOnce(value)) - jest.doMock('../string.js', () => ({ - ...jest.requireActual('../string.js'), + jest.doMock('../string', () => ({ + ...jest.requireActual('../string'), randomString, })) let utility jest.isolateModules(() => { - utility = require('../utility.js') + utility = require('../utility') }) return { ...utility, randomString } diff --git a/src/core/utils/_envs.js b/src/core/utils/_envs.js deleted file mode 100644 index 1004a06c..00000000 --- a/src/core/utils/_envs.js +++ /dev/null @@ -1,55 +0,0 @@ -import { LANGUAGE } from './constants' - -/** - * Environment Variables - * @note: for Next.js, explicitly set variable on initialisation like so: - * import config from 'next/config' - * import { ENV } from './' - * - * Object.assign(ENV, config().publicRuntimeConfig) - */ -export let ENV = (typeof process !== 'undefined' && process.env) || {} -export const NODE_ENV = ENV.NODE_ENV // @Note: Next.js does not automatically add NODE_ENV, set inside next.config.js -export const __PROD__ = NODE_ENV === 'production' -export const __STAGE__ = NODE_ENV === 'stage' -export const __TEST__ = NODE_ENV === 'test' -export const __DEV__ = NODE_ENV === 'development' -export const __CLIENT__ = typeof window !== 'undefined' -export const __BACKEND__ = !__CLIENT__ -export const __IOS__ = false -export const _INIT_ = __BACKEND__ && (__PROD__ || __STAGE__) -export const _WORK_DIR_ = typeof process !== 'undefined' ? process.cwd() : '.' // relative to root `index.js` -export const UNDEFINED = (Undefined => Undefined)() - -/* Globally Accessible Objects */ -export const Active = { - // will be overridden at runtime, used for avoiding circular import and env-dependent libraries - DEFAULT: {LANGUAGE: LANGUAGE.ENGLISH._}, - LANG: LANGUAGE.ENGLISH, // currently used language - Storage: typeof localStorage !== 'undefined' ? localStorage : undefined, // LocalStorage for Node - WebSocket: typeof WebSocket !== 'undefined' ? WebSocket : undefined, // WebSocket for Node - history: {}, // Cross Platform route history object - iconClass: '', // CSS className for - iconClassPrefix: 'icon-', // CSS className prefix for - client: undefined, // Apollo client - log: undefined, // backend console logger - user: {}, // the current user, for quick access to user info, such as auth - usersById: {}, // for storing temporary info, like user.lastOnline - translate: (value) => value, // Global translate function - - /** - * Password Strength Calculator - * @example: