Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/explicit-any-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'react-simplikit': minor
---

The emitted declarations of `usePreservedCallback`, `useDebounce`, `useThrottle` and `useCallbackOncePerRender` now name their callback parameter type `CallerTypedArgs` instead of `any[]`. It is the same type, so they accept the same callbacks. `useThrottle` also constrains the callback's return type to `unknown` instead of `any`, which accepts the same callbacks.

The throttled function returned by `useThrottle` now accepts the callback's parameters instead of `any[]`, so calling it with arguments the callback does not take is a type error. The return types of `useThrottle` and `useDebounce` are now written out in their declarations, so a consumer that emits declarations can export their result without a TS2742 error.
2 changes: 1 addition & 1 deletion .cursorrules
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Unidirectional layers: `components → hooks → utils → _internal`
- `type` over `interface` for type aliases
- Named functions in useEffect: `useEffect(function handleResize() { ... }, [])`
- Named exports only, no default exports
- No `any` — strict TypeScript
- No `any` in implementation (lint error) — for the parameters of a caller-supplied function, use `CallerTypedArgs` from `_internal/types.ts`
- `.ts`/`.tsx` extensions in source imports (tsdown rewrites to `.mjs`/`.cjs`)
- No implicit boolean coercion: `if (value)` → `if (value != null)` (enforced by `strict-boolean-expressions`)
- Nullish checks: `== null` for both null and undefined, `!== undefined` only when distinction matters
Expand Down
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
- Use `type` not `interface` for type aliases
- Named functions in useEffect: `useEffect(function handleResize() { ... }, [])`
- No default exports — named exports only
- No `any` types — strict TypeScript
- No `any` in implementation (lint error) — for the parameters of a caller-supplied function, use `CallerTypedArgs` from `_internal/types.ts`
- Use `.ts`/`.tsx` extensions in source imports (tsdown rewrites them to `.mjs`/`.cjs`)
- No implicit boolean coercion: `if (value)` → `if (value != null)` (enforced by `strict-boolean-expressions`)
- Nullish checks: `== null` for both null and undefined, `!== undefined` only when distinction matters
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"moduleResolution": "bundler",
"target": "es2020",
"strict": true,
"declaration": true,
"noEmit": true,
"skipLibCheck": false,
"types": []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"moduleResolution": "node16",
"target": "es2020",
"strict": true,
"declaration": true,
"noEmit": true,
"skipLibCheck": false,
"types": []
Expand Down
13 changes: 11 additions & 2 deletions .scripts/verify-pack/fixtures/consumer/types-consumer.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { useNetworkStatus, useToggle } from 'react-simplikit';
import { useDebounce, useNetworkStatus, useThrottle, useToggle } from 'react-simplikit';

type UseToggleReturn = ReturnType<typeof useToggle>;
const toggleReturn: [boolean, () => void] = undefined as unknown as UseToggleReturn;

type NetworkStatusReturn = ReturnType<typeof useNetworkStatus>;

export { toggleReturn };
// Inferred return types fail declaration emit (TS2742) when they reference a type the package entry does not export
function useThrottledLog() {
return useThrottle((y: number) => console.log(y), 16);
}

function useDebouncedLog() {
return useDebounce((query: string) => console.log(query), 300);
}

export { toggleReturn, useDebouncedLog, useThrottledLog };
export type { NetworkStatusReturn };
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ src/hooks/useHookName/
- **No implicit boolean coercion** — `if (value)` → `if (value != null)` (enforced by `strict-boolean-expressions`)
- **Import extensions** — Use `.ts`/`.tsx` extensions in source imports (tsdown rewrites them to `.mjs`/`.cjs` in the output)
- **Named exports only** — No default exports
- **No `any` types** — Full TypeScript strict mode
- **No `any` in implementation** — Full TypeScript strict mode, and lint rejects `any`. For the parameter list of a function the caller passes in, use `CallerTypedArgs` from `src/_internal/types.ts`
- **Zero runtime dependencies**

### Nullish Checks and Control Flow
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ src/
- **useEffect cleanup** — Always return cleanup to remove listeners/subscriptions
- **`"use client"` banner** — tsdown adds this to every emitted file for RSC compatibility
- **Named exports only** — No default exports
- **No `any` types** — Full TypeScript strict mode, no escape hatches
- **No `any` in implementation** — Full TypeScript strict mode, and lint rejects `any`. The only exception is `CallerTypedArgs` in `src/_internal/types.ts`, the parameter list of a function the caller passes in
- **Zero dependencies** — No runtime dependencies in `react-simplikit`. `react-simplikit-codemod` is a dev-time CLI and declares `commander`, `detect-indent`, `fast-glob`, `semver` and `typescript` in `dependencies`

### SSR-Safe Coding Pattern
Expand Down
2 changes: 1 addition & 1 deletion docs/design-principles.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,4 @@ The documentation is written in English and translated into several languages; a
- **Strict Type Definitions**: All parameters, return values, and options are fully typed
- **IntelliSense Support**: Get autocompletion and inline documentation in your IDE
- **Generic Types**: Flexible APIs that preserve your type information
- **No `any` Types**: We avoid escape hatches that compromise type safety
- **No `any` in Implementation**: Implementation code never uses `any`. The one exception is generic APIs that wrap your function, such as `usePreservedCallback`: the parameter types of the function you pass come from your annotations or the inference context, and fall back to `any` when there is neither
2 changes: 1 addition & 1 deletion docs/es/design-principles.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,4 @@ La documentación se escribe en inglés y se traduce a varios idiomas; una pági
- **Definiciones de tipos estrictas**: todos los parámetros, valores de retorno y opciones están completamente tipados
- **Compatibilidad con IntelliSense**: obtén autocompletado y documentación integrada en tu IDE
- **Tipos genéricos**: APIs flexibles que preservan tu información de tipos
- **Sin tipos `any`**: evitamos las vías de escape que comprometen la seguridad de tipos
- **Sin `any` en la implementación**: el código de implementación nunca usa `any`. La única excepción son las APIs genéricas que envuelven tu función, como `usePreservedCallback`: los tipos de los parámetros de tu función vienen de tus anotaciones o del contexto de inferencia, y quedan como `any` cuando no hay ninguno de los dos
13 changes: 9 additions & 4 deletions docs/hook-design-principles.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,16 +115,21 @@ useEffect(function tick() {
}, []);
```

#### C4. No `any` Types
#### C4. No `any` in Implementation

Use generics `<T>`. `any` propagates and defeats the type system. Justified `eslint-disable` with comment is acceptable for generic callback types.
Use generics `<T>`. `any` propagates and defeats the type system, so lint rejects it in implementation code. The only exception is the parameter list of a function the caller passes in: `unknown[]` would reject annotated callbacks, because function parameters are compared contravariantly. Use the shared `CallerTypedArgs` type from `_internal/types.ts` there instead of writing `any`.

```ts
// ✅ Generic
function useDebounce<T>(value: T, delay: number): T;

// ✅ Justified exception (comment required)
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic callback
// ✅ Caller's function: its parameter types come from the caller's annotations
function useThrottle<F extends (...args: CallerTypedArgs) => unknown>(
callback: F,
wait: number
);

// ❌ `any` written directly anywhere outside `_internal/types.ts`, even with an eslint-disable comment
type AnyFunction = (...args: any[]) => unknown;
```

Expand Down
2 changes: 1 addition & 1 deletion docs/ja/design-principles.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,4 @@
- **厳密な型定義**: すべてのパラメータ、戻り値、オプションが完全に型付けされています
- **IntelliSense サポート**: IDE で自動補完とインラインドキュメントを利用できます
- **ジェネリック型**: 型情報を保持する柔軟な API を提供します
- **`any` 型を使用しない**: 型安全性を損なうエスケープハッチを避けています
- **実装コードで `any` を使用しない**: 実装コードでは `any` を使いません。唯一の例外は、`usePreservedCallback` のように渡された関数をラップするジェネリック API です。渡した関数のパラメータの型は、利用者自身の型注釈または推論のコンテキストで決まり、どちらもない場合は `any` になります
2 changes: 1 addition & 1 deletion docs/ko/design-principles.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,4 @@
- **엄격한 타입 정의**: 모든 매개변수, 반환 값, 옵션이 완전히 타입화되어 있어요
- **IntelliSense 지원**: IDE에서 자동완성과 인라인 문서를 제공받을 수 있어요
- **제네릭 타입**: 타입 정보를 보존하는 유연한 API를 제공해요
- **`any` 타입 없음**: 타입 안전성을 손상시키는 escape hatch를 사용하지 않아요
- **구현 코드에 `any` 없음**: 구현 코드에서는 `any`를 쓰지 않아요. 유일한 예외는 `usePreservedCallback`처럼 전달한 함수를 감싸는 제네릭 API예요. 전달한 함수의 매개변수 타입은 직접 명시한 타입이나 추론 맥락에서 정해지고, 둘 다 없으면 `any`가 돼요
15 changes: 10 additions & 5 deletions docs/ko/hook-design-principles.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,16 +107,21 @@ useEffect(function tick() {
}, []);
```

#### C4. No `any` Types
#### C4. No `any` in Implementation

제네릭 `<T>` 사용. any 전파 → 타입 시스템 무력화. 정당한 사유(generic callback 등) 시 per-line eslint-disable + 코멘트 허용.
제네릭 `<T>` 사용. `any`는 전파되어 타입 시스템 무력화 → 구현 코드의 `any`는 lint가 거부. 유일한 예외는 호출자가 넘기는 함수의 파라미터 목록. 함수 파라미터는 반공변적으로 비교 → `unknown[]`은 타입을 명시한 콜백을 거부. 이 자리에는 `any` 대신 `_internal/types.ts`의 공용 타입 `CallerTypedArgs` 사용.

```ts
// ✅ Generic
// ✅ 제네릭
function useDebounce<T>(value: T, delay: number): T;

// ✅ 정당한 예외 (코멘트 필수)
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic callback
// ✅ 호출자의 함수: 파라미터 타입은 호출자가 명시한 타입을 따름
function useThrottle<F extends (...args: CallerTypedArgs) => unknown>(
callback: F,
wait: number
);

// ❌ `_internal/types.ts` 밖에서 `any`를 직접 씀 (eslint-disable 주석이 있어도 금지)
type AnyFunction = (...args: any[]) => unknown;
```

Expand Down
2 changes: 1 addition & 1 deletion docs/zh-Hans/design-principles.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,4 @@
- **严格的类型定义**:所有参数、返回值和选项都有完整的类型
- **IntelliSense 支持**:在 IDE 中获得自动补全和内联文档
- **泛型**:灵活的 API,保留你的类型信息
- **不使用 `any` 类型**:我们避免使用会破坏类型安全的脱围机制
- **实现代码不使用 `any`**:实现代码从不使用 `any`。唯一的例外是像 `usePreservedCallback` 这样包装你的函数的泛型 API:传入函数的参数类型由你自己的类型注解或推断上下文决定,两者都没有时会变成 `any`
9 changes: 8 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export default defineConfig(
import: importPlugin,
},
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/strict-boolean-expressions': 'error',
'@typescript-eslint/consistent-type-definitions': ['warn', 'type'],
'react/react-in-jsx-scope': 'off',
Expand Down Expand Up @@ -124,5 +124,12 @@ export default defineConfig(
},
},
},
{
// Only implementation code must be free of `any`; tests may use it for mocks and fixtures.
files: ['**/*.{spec,test}.{ts,tsx}', '**/_internal/test-utils/**'],
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
},
},
prettierRecommended
);
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ such as when handling scroll or resize events.
## Interface

```ts
function useThrottle<F extends (...args: any[]) => any>(
function useThrottle<F extends (...args: any[]) => unknown>(
callback: F,
wait: number,
options?: { edges?: Array<'leading' | 'trailing'> }
Expand Down
7 changes: 7 additions & 0 deletions packages/react-simplikit/src/_internal/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Parameter list of a function the caller passes in.
* The types come from the caller's annotations or the inference context, and fall back to `any` when there is neither.
* `unknown[]` would reject annotated callbacks, because function parameters are compared contravariantly.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- the only `any` allowed in implementation code
export type CallerTypedArgs = any[];
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { DependencyList, useEffect, useRef } from 'react';

import type { CallerTypedArgs } from '../../_internal/types.ts';
import { usePreservedCallback } from '../usePreservedCallback/index.ts';

/**
Expand Down Expand Up @@ -39,7 +39,10 @@ import { usePreservedCallback } from '../usePreservedCallback/index.ts';
* return <div>User page</div>;
* }
*/
export function useCallbackOncePerRender<F extends (...args: any[]) => void>(callback: F, deps: DependencyList) {
export function useCallbackOncePerRender<F extends (...args: CallerTypedArgs) => void>(
callback: F,
deps: DependencyList
) {
// Same reason as `useAsyncEffect`: the body itself is compiler-clean, but React Compiler
// bails on any function carrying a React ESLint suppression, and the
// `react-hooks/exhaustive-deps` suppression below is unavoidable for a caller-supplied `deps`.
Expand Down
8 changes: 4 additions & 4 deletions packages/react-simplikit/src/hooks/useDebounce/debounce.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Simplified version of https://github.com/toss/es-toolkit/blob/main/src/function/debounce.ts

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic function constraint requires `any[]` due to parameter contravariance
export type DebouncedFunction<F extends (...args: any[]) => void> = {
import type { CallerTypedArgs } from '../../_internal/types.ts';

export type DebouncedFunction<F extends (...args: CallerTypedArgs) => void> = {
(...args: Parameters<F>): void;
cancel: () => void;
};
Expand All @@ -17,8 +18,7 @@ type DebounceOptions = {
edges?: Array<'leading' | 'trailing'>;
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic function constraint requires `any[]` due to parameter contravariance
export function debounce<F extends (...args: any[]) => void>(
export function debounce<F extends (...args: CallerTypedArgs) => void>(
func: F,
debounceMs: number,
{ edges = ['leading', 'trailing'] }: DebounceOptions = {}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useEffect } from 'react';
import { useMemo } from 'react';

import type { CallerTypedArgs } from '../../_internal/types.ts';
import { usePreservedCallback } from '../usePreservedCallback/index.ts';

import { debounce, DebouncedFunction } from './debounce.ts';
import { debounce } from './debounce.ts';

type DebounceOptions = {
leading?: boolean;
Expand Down Expand Up @@ -61,11 +61,12 @@ type DebounceOptions = {
* );
* }
*/
export function useDebounce<F extends (...args: any[]) => unknown>(
export function useDebounce<F extends (...args: CallerTypedArgs) => unknown>(
callback: F,
wait: number,
options: DebounceOptions = {}
): DebouncedFunction<F> {
// Written out because `DebouncedFunction` is not exported from the package entry, so consumers emitting declarations cannot name it (TS2742)
): { (...args: Parameters<F>): void; cancel: () => void } {
const preservedCallback = usePreservedCallback(callback) as F;

const { leading = false, trailing = true } = options;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useCallback, useEffect, useRef } from 'react';

import type { CallerTypedArgs } from '../../_internal/types.ts';

/**
* @description
* `usePreservedCallback` is a React hook that maintains a stable reference to a callback function
Expand Down Expand Up @@ -29,7 +31,7 @@ import { useCallback, useEffect, useRef } from 'react';
* return <button onClick={handleClick}>Click me</button>;
* }
*/
export function usePreservedCallback<Arguments extends any[] = any[], ReturnValue = unknown>(
export function usePreservedCallback<Arguments extends CallerTypedArgs = CallerTypedArgs, ReturnValue = unknown>(
callback: (...args: Arguments) => ReturnValue
) {
const callbackRef = useRef(callback);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type StorageStateOptionsWithSerializer<T> = StorageStateOptions<T> & {
deserializer: (value: string) => Serializable<T>;
};

type SerializableGuard<T extends readonly any[]> = T[0] extends any
type SerializableGuard<T extends readonly unknown[]> = T[0] extends unknown
? T
: T[0] extends never
? 'Received a non-serializable value'
Expand All @@ -33,7 +33,7 @@ const emitListeners = () => {
listeners.forEach(listener => listener());
};

function isPlainObject(value: unknown): value is Record<PropertyKey, any> {
function isPlainObject(value: unknown): value is Record<PropertyKey, unknown> {
if (typeof value !== 'object') {
return false;
}
Expand All @@ -49,7 +49,7 @@ function isPlainObject(value: unknown): value is Record<PropertyKey, any> {
return Object.prototype.toString.call(value) === '[object Object]';
}

const ensureSerializable = <T extends readonly any[]>(value: T): SerializableGuard<T> => {
const ensureSerializable = <T extends readonly unknown[]>(value: T): SerializableGuard<T> => {
if (
value[0] != null &&
!['string', 'number', 'boolean'].includes(typeof value[0]) &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ por ejemplo, al manejar eventos de desplazamiento o de cambio de tamaño.
## Interfaz

```ts
function useThrottle<F extends (...args: any[]) => any>(
function useThrottle<F extends (...args: any[]) => unknown>(
callback: F,
wait: number,
options?: { edges?: Array<'leading' | 'trailing'> }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
## インターフェース

```ts
function useThrottle<F extends (...args: any[]) => any>(
function useThrottle<F extends (...args: any[]) => unknown>(
callback: F,
wait: number,
options?: { edges?: Array<'leading' | 'trailing'> }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
## 인터페이스

```ts
function useThrottle<F extends (...args: any[]) => any>(
function useThrottle<F extends (...args: any[]) => unknown>(
callback: F,
wait: number,
options?: { edges?: Array<'leading' | 'trailing'> }
Expand Down
6 changes: 3 additions & 3 deletions packages/react-simplikit/src/hooks/useThrottle/throttle.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// Simplified version of https://github.com/toss/es-toolkit/blob/main/src/function/throttle.ts

import type { CallerTypedArgs } from '../../_internal/types.ts';
import { debounce } from '../useDebounce/debounce.ts';

type ThrottleOptions = {
Expand All @@ -14,12 +14,12 @@ type ThrottleOptions = {
edges?: Array<'leading' | 'trailing'>;
};

type ThrottledFunction<F extends (...args: any[]) => void> = {
type ThrottledFunction<F extends (...args: CallerTypedArgs) => void> = {
(...args: Parameters<F>): void;
cancel: () => void;
};

export function throttle<F extends (...args: any[]) => void>(
export function throttle<F extends (...args: CallerTypedArgs) => void>(
func: F,
throttleMs: number,
{ edges = ['leading', 'trailing'] }: ThrottleOptions = {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ such as when handling scroll or resize events.
## Interface

```ts
function useThrottle<F extends (...args: any[]) => any>(
function useThrottle<F extends (...args: any[]) => unknown>(
callback: F,
wait: number,
options?: { edges?: Array<'leading' | 'trailing'> }
Expand Down
Loading
Loading