Skip to content

Let a config type the modify config options - #409

Merged
ai merged 1 commit into
ai:mainfrom
JounQin:fix/modify-config-typings
Oct 7, 2026
Merged

ai merged 1 commit into
ai:mainfrom
JounQin:fix/modify-config-typings

Conversation

@JounQin

@JounQin JounQin commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

modifyEsbuildConfig, modifyRolldownConfig and modifyWebpackConfig were typed as generic functions, and nothing compiles against that:

modifier <T>(config?: T) => T
config => config ❌ Type 'T | undefined' is not assignable to type 'T'
config => ({ ...config, external: ['dep'] }) ❌ Type '{ external: string[]; }' is not assignable to type 'T'
config => { config.external = ['dep']; return config } ❌ Property 'external' does not exist on type 'T'
(config: BuildOptions) => ({ ...config, external: ['dep'] }) ❌ Types of parameters 'config' and 'config' are incompatible

The optional parameter types the argument as T | undefined, which can never be returned as T, and the generic return is what rejects a modifier annotated with the bundler's own type.

Why the generic cannot be kept

  • Assigning a monomorphic function to a generic signature is by design rather than a gap: <T extends object>(config: T) => T promises "for every T, return that same T", which a modifier typed for one config cannot promise — with T = { a: 1, b: 2 } nothing guarantees the returned object still has b. TypeScript requires the source to be generic as well, and <T extends object>(config: T) => config is accepted, but no config writes that. Upstream: #17574 (open suggestion) and #5494, closed as By Design with this same error.
  • The untyped arrow failing is TypeScript's known context-sensitive inference limitation: #47599 "Improve inference in the presence of context-sensitive expressions" (open, Help Wanted, Experimentation Needed). #63545 was closed as a duplicate of it in June 2026, where the answer was "#47599 is not completely solved in 6.0 … any time you have an object literal with unannotated parameters involved in generic inference, that's a context-sensitive inference problem".
  • None of it is version-specific: TypeScript 5.4.5, 5.9.2 and 7.0.2 fail these the same way.

What this does instead

modifyEsbuildConfig?: (config: object) => object
modifyRolldownConfig?: (config: object) => object
modifyWebpackConfig?: (config: object) => object

The core does not know the bundlers' types, so the config brings them:

import type { BuildOptions } from 'esbuild'
import type { SizeLimitConfig } from 'size-limit'

export default [
  {
    path: 'index.js',
    modifyEsbuildConfig: (config: BuildOptions) => ({ ...config, external: ['dep'] })
  }
] satisfies SizeLimitConfig

An untyped arrow still compiles; it just checks nothing, and reading or writing the config is what needs the annotation.

The parameter is required, because the plugins always call a modifier with the config they have just built — check.esbuildConfig = check.modifyEsbuildConfig(check.esbuildConfig) in step 20, and the same in the webpack and rolldown plugins. With config? the argument is object | undefined, so a modifier cannot write to the config without handling undefined.

No new dependencies, no peer declarations, no lockfile change. The README line that described the esbuild option as modifying the webpack config is fixed as well.

Verification

  • tsc --strict against the real esbuild, rolldown and webpack types: the annotated modifiers above compile, including writing to a webpack config (config.mode = 'production'), under both nodenext and bundler resolution.
  • The same config with the parameter left unannotated compiles too.
  • A consumer without any of the three bundlers is unaffected: the declaration no longer refers to them.
  • oxlint is clean.

Copilot AI balanced review requested due to automatic review settings October 7, 2026 13:58

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@git-credential-broker
git-credential-broker Bot force-pushed the fix/modify-config-typings branch from d62dfa9 to fe7730b Compare October 7, 2026 14:39
@JounQin JounQin changed the title Accept plain functions in the modify config options Type the modify config options with the bundlers own types Oct 7, 2026
@JounQin JounQin changed the title Type the modify config options with the bundlers own types Type the modify config options with the bundler's own types Oct 7, 2026
@JounQin JounQin closed this Oct 7, 2026
@JounQin JounQin reopened this Oct 7, 2026
@git-credential-broker
git-credential-broker Bot force-pushed the fix/modify-config-typings branch from fe7730b to 32c9d63 Compare October 7, 2026 14:59
@JounQin JounQin closed this Oct 7, 2026
@JounQin JounQin reopened this Oct 7, 2026
@ai

ai commented Oct 7, 2026

Copy link
Copy Markdown
Owner

Honestly, this change doesn't look good. It is very rare feature and we have too many changes which could create issues in the future.

Let's keep simple here and ask user to type config. Is it OK?

@JounQin

JounQin commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor Author

Before deciding, here is what the current declaration actually allows, plus the two alternatives that came up. Measured with tsc --strict against the real esbuild types:

modifier <T>(config?: T) => T (today) <T>(config: T) => T (config: object) => object
config => config ❌ Type 'T | undefined' is not assignable to type 'T' ✅ ✅
config => ({ ...config }) ❌ Type '{}' is not assignable to type 'T' ✅ ✅
config => ({ ...config, external: ['dep'] }) ❌ Type '{ external: string[]; }' is not assignable to type 'T' ✅ ✅
config => { config.external = ['dep']; return config } ❌ Property 'external' does not exist on type 'T' ❌ same ❌ needs the annotation
(config: BuildOptions) => ({ ...config, external: ['dep'] }) ❌ Types of parameters 'config' and 'config' are incompatible ❌ not assignable to type T ✅
(config?: BuildOptions) => ({ ...config, external: ['dep'] }) ❌ same ❌ not assignable to type T ✅
<T extends object>(config: T) => config ❌ ✅ ✅

Nothing compiles against what we have today, not even config => config: the optional parameter types config as T | undefined, which can never be returned as T. The option being .size-limit.js-only is probably why that went unnoticed.

Dropping the ? alone does not get us to "let the user type the config". (config: BuildOptions) => ({ ...config, external: ['dep'] }) is still Type '{ …65 more…; external: string[]; }' is not assignable to type 'T', because a returned object can never be the type parameter, and writing to the config is still Property 'external' does not exist on type 'T'.

So there are two shapes to choose from:

  • Drop the generic: modifyEsbuildConfig?: (config: object) => object, with a required parameter — the plugins always call a modifier with the config they have just built (check.esbuildConfig = check.modifyEsbuildConfig(check.esbuildConfig) in step 20). (config: BuildOptions) => ({ ...config, external: ['dep'] }) compiles then, an untyped arrow compiles but checks nothing, and writing to the config needs the annotation. Three declarations plus a doc comment each: no peer dependencies, no @ts-ignore, no lockfile change. Of the three columns above, this is the only one where "ask the user to type config" is true.
  • This branch: the bundler's own type without an annotation, at the cost you listed — three optional peers, three ignored imports, a lockfile change.

If the objection is the machinery rather than the option, I will reduce the PR to the first one. If the option is rare enough that its type may stay as it is, that is a decision too — but it is why the current state is worth one of the two, rather than neither.

@ai

ai commented Oct 7, 2026

Copy link
Copy Markdown
Owner

Let's go with Drop the generic, please update PR. User will need to bring own types but it doesn't looks like bad DX:

modifyEsbuildConfig((config: EsbuildConfig) => {
})

@git-credential-broker
git-credential-broker Bot force-pushed the fix/modify-config-typings branch from 32c9d63 to c6799b3 Compare October 7, 2026 20:42
@JounQin JounQin changed the title Type the modify config options with the bundler's own types Let a config type the modify config options Oct 7, 2026
@JounQin

JounQin commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

Done — reduced to the three declarations:

modifyEsbuildConfig?: (config: object) => object
modifyRolldownConfig?: (config: object) => object
modifyWebpackConfig?: (config: object) => object

No peers, no @ts-ignored imports, no lockfile change. (config: BuildOptions) => ({ ...config, external: ['dep'] }) compiles now, which is what was missing.

One detail beyond the generic: the parameter is required rather than config?. The plugins always call a modifier with the config they have just built (check.esbuildConfig = check.modifyEsbuildConfig(check.esbuildConfig) in step 20), and with config? the argument is object | undefined, so a modifier could not write to the config without handling undefined itself. Say the word if you would rather keep it optional.

The README line that called the esbuild option's config "webpack" is fixed in the same commit.

@JounQin JounQin closed this Oct 7, 2026
@JounQin JounQin reopened this Oct 7, 2026
@git-credential-broker
git-credential-broker Bot force-pushed the fix/modify-config-typings branch from c6799b3 to ccb1cef Compare October 7, 2026 21:06
@JounQin JounQin closed this Oct 7, 2026
@JounQin JounQin reopened this Oct 7, 2026
@JounQin JounQin closed this Oct 7, 2026
@JounQin JounQin reopened this Oct 7, 2026
Comment thread packages/size-limit/index.d.ts Outdated
`modifyEsbuildConfig`, `modifyRolldownConfig` and `modifyWebpackConfig`
were typed as generic functions, `<T extends object>(config?: T) => T`,
and nothing compiles against that. The optional parameter types the
argument as `T | undefined`, which can never be returned as `T`, and a
modifier annotated with the bundler's own type fails just as well,
because a returned object can never be the type parameter:

    modifyEsbuildConfig: config => config
    // Type 'T | undefined' is not assignable to type 'T'

    modifyEsbuildConfig: (config: BuildOptions) => ({ ...config, external: [] })
    // Type '{ …; external: string[]; }' is not assignable to type 'T'

They take and return `object` now, so a config annotates the parameter
with the bundler's own type: the option wants a function over a config
the core does not know, and the config is what has that type.

    modifyEsbuildConfig: (config: BuildOptions) => ({ ...config, external: [] })

The parameter is required, because the plugins always call a modifier
with the config they have just built (`check.esbuildConfig =
check.modifyEsbuildConfig(check.esbuildConfig)` in step 20); optional
would leave an unannotated modifier with `object | undefined`, unable to
write to the config at all.

The README entry for `modifyEsbuildConfig` mentioned the webpack config.
@JounQin
JounQin force-pushed the fix/modify-config-typings branch from ccb1cef to 7e1c9d3 Compare October 7, 2026 21:36
@ai
ai merged commit 30541b4 into ai:main Oct 7, 2026
3 checks passed
@JounQin
JounQin deleted the fix/modify-config-typings branch October 7, 2026 21:45
@ai

ai commented Oct 7, 2026

Copy link
Copy Markdown
Owner

Thanks. Types was released in 14.2.0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants