Skip to content

Conversation

@mmkal
Copy link
Contributor

@mmkal mmkal commented Jul 2, 2025

Fixes #2214

From docs:


Prevent usage of variables from outside the scope of isolated functions

💼 This rule is enabled in the ✅ recommended config.

Some functions need to be isolated from their surrounding scope due to execution context constraints. For example, functions passed to makeSynchronous() are executed in a worker or subprocess and cannot access variables from outside their scope. This rule helps identify when functions are using external variables that may cause runtime errors.

Common scenarios where functions must be isolated:

  • Functions passed to makeSynchronous() (executed in worker)
  • Functions that will be serialized via Function.prototype.toString()
  • Server actions or other remote execution contexts
  • Functions with specific JSDoc annotations

By default, this rule allows global variables (like console, fetch, etc.) in isolated functions, but prevents usage of variables from the surrounding scope.

Examples

import makeSynchronous from 'make-synchronous';

export const fetchSync = () => {
	const url = 'https://example.com';

	const getText = makeSynchronous(async () => {
		const res = await fetch(url); // ❌ 'url' is not defined in isolated function scope
		return res.text();
	});

	console.log(getText());
};

// ✅ Define all variables within isolated function's scope
export const fetchSync = () => {
	const getText = makeSynchronous(async () => {
		const url = 'https://example.com'; // Variable defined within function scope
		const res = await fetch(url);
		return res.text();
	});

	console.log(getText());
};

// ✅ Alternative: Pass as parameter
export const fetchSync = () => {
	const getText = makeSynchronous(async (url) => { // Variable passed as parameter
		const res = await fetch(url);
		return res.text();
	});

	console.log(getText('https://example.com'));
};

```js
const foo = 'hi';

/** @isolated */
function abc() {
	return foo.slice(); // ❌ 'foo' is not defined in isolated function scope
}

// ✅
/** @isolated */
function abc() {
	const foo = 'hi'; // Variable defined within function scope
	return foo.slice();
}

Options

Type: object

functions

Type: string[]
Default: ['makeSynchronous']

Array of function names that create isolated execution contexts. Functions passed as arguments to these functions will be considered isolated.

selectors

Type: string[]
Default: []

Array of ESLint selectors to identify isolated functions. Useful for custom naming conventions or framework-specific patterns.

{
	'unicorn/isolated-functions': [
		'error',
		{
			selectors: [
				'FunctionDeclaration[id.name=/lambdaHandler.*/]'
			]
		}
	]
}

comments

Type: string[]
Default: ['@isolated']

Array of comment strings that mark functions as isolated. Functions with JSDoc comments containing these strings will be considered isolated.

{
	'unicorn/isolated-functions': [
		'error',
		{
			comments: [
				'@isolated',
				'@remote'
			]
		}
	]
}

globals

Type: boolean | string[]
Default: true

Controls how global variables are handled:

  • false: Global variables are not allowed in isolated functions
  • true (default): All globals from ESLint's language options are allowed
  • string[]: Only the specified global variable names are allowed
{
	'unicorn/isolated-functions': [
		'error',
		{
			globals: ['console', 'fetch'] // Only allow these globals
		}
	]
}

Examples

Custom function names

{
	'unicorn/isolated-functions': [
		'error',
		{
			functions: [
				'makeSynchronous',
				'createWorker',
				'serializeFunction'
			]
		}
	]
}

Lambda function naming convention

{
	'unicorn/isolated-functions': [
		'error',
		{
			selectors: [
				'FunctionDeclaration[id.name=/lambdaHandler.*/]'
			]
		}
	]
}
const foo = 'hi';

function lambdaHandlerFoo() { // ❌ Will be flagged as isolated
	return foo.slice();
}

function someOtherFunction() { // ✅ Not flagged
	return foo.slice();
}

createLambda({
	name: 'fooLambda',
	code: lambdaHandlerFoo.toString(), // Function will be serialized
});

Allowing specific globals

{
	'unicorn/isolated-functions': [
		'error',
		{
			globals: [
				'console',
				'fetch',
				'URL'
			]
		}
	]
}
// ✅ All globals used are explicitly allowed
makeSynchronous(async () => {
	console.log('Starting...'); // ✅ Allowed global
	const response = await fetch('https://api.example.com'); // ✅ Allowed global
	const url = new URL(response.url); // ✅ Allowed global
	return response.text();
});

makeSynchronous(async () => {
	const response = await fetch('https://api.example.com', {
		headers: {
			'Authorization': `Bearer ${process.env.API_TOKEN}` // ❌ 'process' is not in allowed globals
		}
	});
	const url = new URL(response.url);
	return response.text();
});

Note: I didn't go as far as suggested in that issue #2214 (comment) - that is, to track imports from specific modules like import makeSync from 'make-synchronous', to allow for arbitrary aliasing from that package. Instead I went for fairly specific naming conventions. But I thought the rule still had value while relying on naming convention, so opening a pull request now since I don't know when I'll get some more time to spend on it. I think the module-import-tracking thing could go in later as a new feature, if there's enough interest in it.

@fisker fisker changed the title New rule: isolated-functions Add isolated-functions rule Jul 7, 2025
@mmkal mmkal force-pushed the isolated-functions branch from f926ae0 to 26e70a5 Compare July 7, 2025 11:04
@mmkal mmkal force-pushed the isolated-functions branch from 64b3917 to 30eaa9d Compare July 7, 2025 12:02
@mmkal
Copy link
Contributor Author

mmkal commented Jul 7, 2025

Actually maybe I should re-think how globals works. It seems wrong that it works differently from the built-in eslint languageOptions.globals. Maybe it should be more like this:

{
	'unicorn/isolated-functions': [
		'error',
		{
			globals: [
				'node', // globals.node from `globals` package
				'browser', // globals.browser from `globals` package
				{foobar: true}, // also allow this global
				{abc: 'off'}, // disallow this global
				'xyz', // error, this doesn't exist in the `globals` package
		}
	]
}

The above also allows for import globals from 'globals' and spreading the values in directly too, but the string option just adds a shortcut so end-users don't have to manually install globals

@fisker @sindresorhus what do you think?

@fisker
Copy link
Collaborator

fisker commented Jul 7, 2025

so end-users don't have to manually install globals

Users should always have globals installed, I don't think we should worry about it, we can accept the languageOptions.globals style, anyway, I think language builtins should always allowed.


const MESSAGE_ID_EXTERNALLY_SCOPED_VARIABLE = 'externally-scoped-variable';
const messages = {
[MESSAGE_ID_EXTERNALLY_SCOPED_VARIABLE]: 'Variable {{name}} not defined in scope of isolated function. Function is isolated because: {{reason}}.',
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is this reason necessary to show in the message? When user see "in scope of isolated function", they should realize that it's isolated.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I prefer to leave the "isolated reason" alone, but we can keep the "global variable is not writable", it can be useful.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

More information is more useful? If someone is new to a project they may not know that this rule exists, that the function is isolated, or what the team's conventions are. It's just a bit more of a hint about why the rule applies. LLMs will also likely be better at addressing the problem based on an error message which includes the reason. What's the downside to including it?

@fisker
Copy link
Collaborator

fisker commented Oct 27, 2025

I didn't see handling of ES builtin globals.

/** @isolated */
function foo() {
	const map = new Map() // Will this `Map` be reported?
}

I think it should be ignored unless globals: {Map: 'off'}

@mmkal
Copy link
Contributor Author

mmkal commented Oct 27, 2025

I didn't see handling of ES builtin globals.

/** @isolated */
function foo() {
	const map = new Map() // Will this `Map` be reported?
}

I think it should be ignored unless globals: {Map: 'off'}

I have tests for globals.

valid:

{
name: 'all global variables can be explicitly disallowed',
languageOptions: {globals: {foo: true}},
options: [{globals: {}}],
code: outdent`
makeSynchronous(function () {
return foo.slice();
});
`,
errors: [fooInMakeSynchronousError],
},
{
name: 'individual global variables can be explicitly disallowed',
options: [{globals: {URLSearchParams: 'readonly', URL: 'off'}}],
code: outdent`
makeSynchronous(function () {
return new URL('https://example.com?') + new URLSearchParams({a: 'b'}).toString();
});
`,
errors: [error({name: 'URL', reason: 'callee of function named "makeSynchronous"'})],
},
{
name: 'check globals writability',
code: outdent`
makeSynchronous(function () {
location = new URL('https://example.com');
process = {env: {}};
process.env.FOO = 'bar';
});
`,
errors: [
// Only one error, `location = new URL('https://example.com')` and `process.env.FOO = 'bar'` are fine, the problem is `process = {...}`.
error({
name: 'process',
reason: 'callee of function named "makeSynchronous" (global variable is not writable)',
}),
],
},

invalid:

{
name: 'all global variables can be explicitly disallowed',
languageOptions: {globals: {foo: true}},
options: [{globals: {}}],
code: outdent`
makeSynchronous(function () {
return foo.slice();
});
`,
errors: [fooInMakeSynchronousError],
},
{
name: 'individual global variables can be explicitly disallowed',
options: [{globals: {URLSearchParams: 'readonly', URL: 'off'}}],
code: outdent`
makeSynchronous(function () {
return new URL('https://example.com?') + new URLSearchParams({a: 'b'}).toString();
});
`,
errors: [error({name: 'URL', reason: 'callee of function named "makeSynchronous"'})],
},
{
name: 'check globals writability',
code: outdent`
makeSynchronous(function () {
location = new URL('https://example.com');
process = {env: {}};
process.env.FOO = 'bar';
});
`,
errors: [
// Only one error, `location = new URL('https://example.com')` and `process.env.FOO = 'bar'` are fine, the problem is `process = {...}`.
error({
name: 'process',
reason: 'callee of function named "makeSynchronous" (global variable is not writable)',
}),
],
},

They come from languageOptions by default but can be overriden:

const allowedGlobals = options.globals ?? context.languageOptions.globals;

Are you saying you think more tests are needed?

Edit: a8467d7

@fisker
Copy link
Collaborator

fisker commented Oct 27, 2025

I'm talking about https://github.com/sindresorhus/globals/blob/fa8aaaeb9b203468a07e9c2dacec9ca48527aa1f/globals.json#L1195

{
	valid: [
		{
			code: 'makeSynchronous(() => new Array())',
		},
		{
			code: 'makeSynchronous(() => new Array())',
			options: [{globals: {}}],
		},
	],
	invalid: [
		{
			code: 'makeSynchronous(() => new Array())',
			options: [{globals: {Array: 'off'}}],
		},
	]
}

@fisker
Copy link
Collaborator

fisker commented Oct 27, 2025

Maybe we need change

const allowedGlobals = options.globals ?? context.languageOptions.globals; 

to

const allowedGlobals = {
	...(globals[`es${eslintContext.languageOptions.ecmaVersion}`] ?? globals.builtins),
	...(options.globals ?? context.languageOptions.globals)
}; 

@mmkal
Copy link
Contributor Author

mmkal commented Oct 31, 2025

@fisker i see - good idea, done.

@mmkal
Copy link
Contributor Author

mmkal commented Nov 4, 2025

@fisker @sindresorhus ok for this to go in? I'd love to use this at work without using an unstable version of this plugin.

@fisker
Copy link
Collaborator

fisker commented Nov 4, 2025

I'll take a final look.

'unicorn/isolated-functions': [
'error',
{
globals: {} // Empty object disallows all globals
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
globals: {} // Empty object disallows all globals
globals: {} // Empty object disallows all globals except language globals

Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe we should mention that language globals are always enabled; they need to be disabled explicitly.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Do you think worth changing the option to

{
	esGlobals: boolean,
	eslintGlobals: boolean,
	overrideGlobals: {
		...
	}
}

?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

How about I change globals to overrideGlobals and we can add the other ones if/when somebody asks for it?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done. I think the behaviour is less confusing now - before globals: {} could turn off everything in language options, and I don't think it was good for globals: undefined to be so dramatically different from globals: {}.

Now, overrideGlobals does just that - it overrides globals that otherwise are defined, so if you want to turn off globals you turn them off explicitly.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

commit: d9836a0

Copy link
Collaborator

@fisker fisker left a comment

Choose a reason for hiding this comment

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

Everything looks good, just some nit comments.

@mmkal
Copy link
Contributor Author

mmkal commented Nov 4, 2025

Done - left in a couple of console.log things to showcase how you can still pass variables in to isolated functions (console.log(getText('https://example.com'))), but i don't feel that strongly about it, I can remove them too if you prefer.

@fisker
Copy link
Collaborator

fisker commented Nov 4, 2025

There is a question #2701 (comment)

@sindresorhus sindresorhus merged commit 4956a6b into sindresorhus:main Nov 7, 2025
18 checks passed
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.

Rule proposal: isolated-functions

3 participants