diff --git a/.changeset/fresh-cameras-repeat.md b/.changeset/fresh-cameras-repeat.md
new file mode 100644
index 00000000..863aa8b7
--- /dev/null
+++ b/.changeset/fresh-cameras-repeat.md
@@ -0,0 +1,11 @@
+---
+"@effect/language-service": minor
+---
+
+Add the `lazyPromiseInEffectSync` diagnostic to catch `Effect.sync(() => Promise...)` patterns and suggest using `Effect.promise` or `Effect.tryPromise` for async work.
+
+Example:
+
+```ts
+Effect.sync(() => Promise.resolve(1))
+```
diff --git a/README.md b/README.md
index f434cab6..5c35f49c 100644
--- a/README.md
+++ b/README.md
@@ -78,6 +78,7 @@ Some diagnostics are off by default or have a default severity of suggestion, bu
globalErrorInEffectCatch | ⚠️ | | Warns when catch callbacks return global Error type instead of typed errors | ✓ | ✓ |
globalErrorInEffectFailure | ⚠️ | | Warns when the global Error type is used in an Effect failure channel | ✓ | ✓ |
layerMergeAllWithDependencies | ⚠️ | 🔧 | Detects interdependencies in Layer.mergeAll calls where one layer provides a service that another layer requires | ✓ | ✓ |
+ lazyPromiseInEffectSync | ⚠️ | | Warns when Effect.sync lazily returns a Promise instead of using an async Effect constructor | ✓ | ✓ |
leakingRequirements | 💡 | | Detects implementation services leaked in service methods | ✓ | ✓ |
multipleEffectProvide | ⚠️ | 🔧 | Warns against chaining Effect.provide calls which can cause service lifecycle issues | ✓ | ✓ |
returnEffectInGen | 💡 | 🔧 | Warns when returning an Effect in a generator causes nested Effect<Effect<...>> | ✓ | ✓ |
diff --git a/packages/harness-effect-v3/__snapshots__/completions.test.ts.snap b/packages/harness-effect-v3/__snapshots__/completions.test.ts.snap
index a650ad2d..b885bb4f 100644
--- a/packages/harness-effect-v3/__snapshots__/completions.test.ts.snap
+++ b/packages/harness-effect-v3/__snapshots__/completions.test.ts.snap
@@ -248,7 +248,7 @@ exports[`Completion effectDataClasses > effectDataClasses_directImportTaggedErro
exports[`Completion effectDiagnosticsComment > effectDiagnosticsComment.ts at 2:5 1`] = `
[
{
- "insertText": "@effect-diagnostics \${1|anyUnknownInErrorContext,asyncFunction,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,cryptoRandomUUID,cryptoRandomUUIDInEffect,deterministicKeys,duplicatePackage,effectFnIife,effectFnImplicitAny,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalConsole,globalConsoleInEffect,globalDate,globalDateInEffect,globalErrorInEffectCatch,globalErrorInEffectFailure,globalFetch,globalFetchInEffect,globalRandom,globalRandomInEffect,globalTimers,globalTimersInEffect,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,newPromise,nodeBuiltinImport,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,processEnv,processEnvInEffect,redundantSchemaTagIdentifier,returnEffectInGen,runEffectInsideEffect,schemaStructWithTag,schemaSyncInEffect,schemaUnionOfLiterals,scopeInLayerEffect,serviceNotAsClass,strictBooleanExpressions,strictEffectProvide,tryCatchInEffectGen,unknownInEffectCatch,unnecessaryEffectGen,unnecessaryFailYieldableError,unnecessaryPipe,unnecessaryPipeChain,unsupportedServiceAccessors|}:\${2|off,warning,error,message,suggestion|}$0",
+ "insertText": "@effect-diagnostics \${1|anyUnknownInErrorContext,asyncFunction,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,cryptoRandomUUID,cryptoRandomUUIDInEffect,deterministicKeys,duplicatePackage,effectFnIife,effectFnImplicitAny,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalConsole,globalConsoleInEffect,globalDate,globalDateInEffect,globalErrorInEffectCatch,globalErrorInEffectFailure,globalFetch,globalFetchInEffect,globalRandom,globalRandomInEffect,globalTimers,globalTimersInEffect,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,lazyPromiseInEffectSync,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,newPromise,nodeBuiltinImport,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,processEnv,processEnvInEffect,redundantSchemaTagIdentifier,returnEffectInGen,runEffectInsideEffect,schemaStructWithTag,schemaSyncInEffect,schemaUnionOfLiterals,scopeInLayerEffect,serviceNotAsClass,strictBooleanExpressions,strictEffectProvide,tryCatchInEffectGen,unknownInEffectCatch,unnecessaryEffectGen,unnecessaryFailYieldableError,unnecessaryPipe,unnecessaryPipeChain,unsupportedServiceAccessors|}:\${2|off,warning,error,message,suggestion|}$0",
"isSnippet": true,
"kind": "string",
"name": "@effect-diagnostics",
@@ -259,7 +259,7 @@ exports[`Completion effectDiagnosticsComment > effectDiagnosticsComment.ts at 2:
"sortText": "11",
},
{
- "insertText": "@effect-diagnostics-next-line \${1|anyUnknownInErrorContext,asyncFunction,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,cryptoRandomUUID,cryptoRandomUUIDInEffect,deterministicKeys,duplicatePackage,effectFnIife,effectFnImplicitAny,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalConsole,globalConsoleInEffect,globalDate,globalDateInEffect,globalErrorInEffectCatch,globalErrorInEffectFailure,globalFetch,globalFetchInEffect,globalRandom,globalRandomInEffect,globalTimers,globalTimersInEffect,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,newPromise,nodeBuiltinImport,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,processEnv,processEnvInEffect,redundantSchemaTagIdentifier,returnEffectInGen,runEffectInsideEffect,schemaStructWithTag,schemaSyncInEffect,schemaUnionOfLiterals,scopeInLayerEffect,serviceNotAsClass,strictBooleanExpressions,strictEffectProvide,tryCatchInEffectGen,unknownInEffectCatch,unnecessaryEffectGen,unnecessaryFailYieldableError,unnecessaryPipe,unnecessaryPipeChain,unsupportedServiceAccessors|}:\${2|off,warning,error,message,suggestion|}$0",
+ "insertText": "@effect-diagnostics-next-line \${1|anyUnknownInErrorContext,asyncFunction,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,cryptoRandomUUID,cryptoRandomUUIDInEffect,deterministicKeys,duplicatePackage,effectFnIife,effectFnImplicitAny,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalConsole,globalConsoleInEffect,globalDate,globalDateInEffect,globalErrorInEffectCatch,globalErrorInEffectFailure,globalFetch,globalFetchInEffect,globalRandom,globalRandomInEffect,globalTimers,globalTimersInEffect,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,lazyPromiseInEffectSync,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,newPromise,nodeBuiltinImport,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,processEnv,processEnvInEffect,redundantSchemaTagIdentifier,returnEffectInGen,runEffectInsideEffect,schemaStructWithTag,schemaSyncInEffect,schemaUnionOfLiterals,scopeInLayerEffect,serviceNotAsClass,strictBooleanExpressions,strictEffectProvide,tryCatchInEffectGen,unknownInEffectCatch,unnecessaryEffectGen,unnecessaryFailYieldableError,unnecessaryPipe,unnecessaryPipeChain,unsupportedServiceAccessors|}:\${2|off,warning,error,message,suggestion|}$0",
"isSnippet": true,
"kind": "string",
"name": "@effect-diagnostics-next-line",
diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/lazyPromiseInEffectSync.ts.codefixes b/packages/harness-effect-v3/__snapshots__/diagnostics/lazyPromiseInEffectSync.ts.codefixes
new file mode 100644
index 00000000..fca14860
--- /dev/null
+++ b/packages/harness-effect-v3/__snapshots__/diagnostics/lazyPromiseInEffectSync.ts.codefixes
@@ -0,0 +1,4 @@
+lazyPromiseInEffectSync_skipNextLine from 325 to 349
+lazyPromiseInEffectSync_skipFile from 325 to 349
+lazyPromiseInEffectSync_skipNextLine from 464 to 482
+lazyPromiseInEffectSync_skipFile from 464 to 482
\ No newline at end of file
diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/lazyPromiseInEffectSync.ts.output b/packages/harness-effect-v3/__snapshots__/diagnostics/lazyPromiseInEffectSync.ts.output
new file mode 100644
index 00000000..9b2bb525
--- /dev/null
+++ b/packages/harness-effect-v3/__snapshots__/diagnostics/lazyPromiseInEffectSync.ts.output
@@ -0,0 +1,5 @@
+() => Promise.resolve(1)
+8:46 - 8:70 | 0 | This `Effect.sync` thunk returns a Promise. Use `Effect.promise` or `Effect.tryPromise` to represent async work. effect(lazyPromiseInEffectSync)
+
+() => promiseValue
+11:44 - 11:62 | 0 | This `Effect.sync` thunk returns a Promise. Use `Effect.promise` or `Effect.tryPromise` to represent async work. effect(lazyPromiseInEffectSync)
\ No newline at end of file
diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/lazyPromiseInEffectSync_preview.ts.codefixes b/packages/harness-effect-v3/__snapshots__/diagnostics/lazyPromiseInEffectSync_preview.ts.codefixes
new file mode 100644
index 00000000..54caa6c2
--- /dev/null
+++ b/packages/harness-effect-v3/__snapshots__/diagnostics/lazyPromiseInEffectSync_preview.ts.codefixes
@@ -0,0 +1,2 @@
+lazyPromiseInEffectSync_skipNextLine from 152 to 176
+lazyPromiseInEffectSync_skipFile from 152 to 176
\ No newline at end of file
diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/lazyPromiseInEffectSync_preview.ts.output b/packages/harness-effect-v3/__snapshots__/diagnostics/lazyPromiseInEffectSync_preview.ts.output
new file mode 100644
index 00000000..5a90937f
--- /dev/null
+++ b/packages/harness-effect-v3/__snapshots__/diagnostics/lazyPromiseInEffectSync_preview.ts.output
@@ -0,0 +1,2 @@
+() => Promise.resolve(1)
+5:35 - 5:59 | 0 | This `Effect.sync` thunk returns a Promise. Use `Effect.promise` or `Effect.tryPromise` to represent async work. effect(lazyPromiseInEffectSync)
\ No newline at end of file
diff --git a/packages/harness-effect-v3/examples/diagnostics/lazyPromiseInEffectSync.ts b/packages/harness-effect-v3/examples/diagnostics/lazyPromiseInEffectSync.ts
new file mode 100644
index 00000000..790f7229
--- /dev/null
+++ b/packages/harness-effect-v3/examples/diagnostics/lazyPromiseInEffectSync.ts
@@ -0,0 +1,20 @@
+// @effect-diagnostics lazyPromiseInEffectSync:warning
+import { Effect } from "effect"
+
+declare const promiseValue: Promise
+declare const thenableValue: { then: (onFulfilled: (value: number) => unknown) => unknown }
+
+// Should trigger - Promise returned from Effect.sync
+export const fromPromiseResolve = Effect.sync(() => Promise.resolve(1))
+
+// Should trigger - declared Promise type returned from Effect.sync
+export const fromPromiseValue = Effect.sync(() => promiseValue)
+
+// Should NOT trigger - sync value
+export const fromValue = Effect.sync(() => 1)
+
+// Should NOT trigger - thenable but not Promise
+export const fromThenable = Effect.sync(() => thenableValue)
+
+// Should NOT trigger - already using the async constructor
+export const fromEffectPromise = Effect.promise(() => Promise.resolve(1))
diff --git a/packages/harness-effect-v3/examples/diagnostics/lazyPromiseInEffectSync_preview.ts b/packages/harness-effect-v3/examples/diagnostics/lazyPromiseInEffectSync_preview.ts
new file mode 100644
index 00000000..6ba4ca4d
--- /dev/null
+++ b/packages/harness-effect-v3/examples/diagnostics/lazyPromiseInEffectSync_preview.ts
@@ -0,0 +1,5 @@
+// @effect-diagnostics *:off
+// @effect-diagnostics lazyPromiseInEffectSync:warning
+import { Effect } from "effect"
+
+export const preview = Effect.sync(() => Promise.resolve(1))
diff --git a/packages/harness-effect-v4/__snapshots__/completions.test.ts.snap b/packages/harness-effect-v4/__snapshots__/completions.test.ts.snap
index 1be36423..b0eba606 100644
--- a/packages/harness-effect-v4/__snapshots__/completions.test.ts.snap
+++ b/packages/harness-effect-v4/__snapshots__/completions.test.ts.snap
@@ -109,7 +109,7 @@ exports[`Completion effectDataClasses > effectDataClasses.ts at 4:35 1`] = `
exports[`Completion effectDiagnosticsComment > effectDiagnosticsComment.ts at 2:5 1`] = `
[
{
- "insertText": "@effect-diagnostics \${1|anyUnknownInErrorContext,asyncFunction,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,cryptoRandomUUID,cryptoRandomUUIDInEffect,deterministicKeys,duplicatePackage,effectFnIife,effectFnImplicitAny,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalConsole,globalConsoleInEffect,globalDate,globalDateInEffect,globalErrorInEffectCatch,globalErrorInEffectFailure,globalFetch,globalFetchInEffect,globalRandom,globalRandomInEffect,globalTimers,globalTimersInEffect,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,newPromise,nodeBuiltinImport,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,processEnv,processEnvInEffect,redundantSchemaTagIdentifier,returnEffectInGen,runEffectInsideEffect,schemaStructWithTag,schemaSyncInEffect,schemaUnionOfLiterals,scopeInLayerEffect,serviceNotAsClass,strictBooleanExpressions,strictEffectProvide,tryCatchInEffectGen,unknownInEffectCatch,unnecessaryEffectGen,unnecessaryFailYieldableError,unnecessaryPipe,unnecessaryPipeChain,unsupportedServiceAccessors|}:\${2|off,warning,error,message,suggestion|}$0",
+ "insertText": "@effect-diagnostics \${1|anyUnknownInErrorContext,asyncFunction,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,cryptoRandomUUID,cryptoRandomUUIDInEffect,deterministicKeys,duplicatePackage,effectFnIife,effectFnImplicitAny,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalConsole,globalConsoleInEffect,globalDate,globalDateInEffect,globalErrorInEffectCatch,globalErrorInEffectFailure,globalFetch,globalFetchInEffect,globalRandom,globalRandomInEffect,globalTimers,globalTimersInEffect,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,lazyPromiseInEffectSync,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,newPromise,nodeBuiltinImport,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,processEnv,processEnvInEffect,redundantSchemaTagIdentifier,returnEffectInGen,runEffectInsideEffect,schemaStructWithTag,schemaSyncInEffect,schemaUnionOfLiterals,scopeInLayerEffect,serviceNotAsClass,strictBooleanExpressions,strictEffectProvide,tryCatchInEffectGen,unknownInEffectCatch,unnecessaryEffectGen,unnecessaryFailYieldableError,unnecessaryPipe,unnecessaryPipeChain,unsupportedServiceAccessors|}:\${2|off,warning,error,message,suggestion|}$0",
"isSnippet": true,
"kind": "string",
"name": "@effect-diagnostics",
@@ -120,7 +120,7 @@ exports[`Completion effectDiagnosticsComment > effectDiagnosticsComment.ts at 2:
"sortText": "11",
},
{
- "insertText": "@effect-diagnostics-next-line \${1|anyUnknownInErrorContext,asyncFunction,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,cryptoRandomUUID,cryptoRandomUUIDInEffect,deterministicKeys,duplicatePackage,effectFnIife,effectFnImplicitAny,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalConsole,globalConsoleInEffect,globalDate,globalDateInEffect,globalErrorInEffectCatch,globalErrorInEffectFailure,globalFetch,globalFetchInEffect,globalRandom,globalRandomInEffect,globalTimers,globalTimersInEffect,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,newPromise,nodeBuiltinImport,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,processEnv,processEnvInEffect,redundantSchemaTagIdentifier,returnEffectInGen,runEffectInsideEffect,schemaStructWithTag,schemaSyncInEffect,schemaUnionOfLiterals,scopeInLayerEffect,serviceNotAsClass,strictBooleanExpressions,strictEffectProvide,tryCatchInEffectGen,unknownInEffectCatch,unnecessaryEffectGen,unnecessaryFailYieldableError,unnecessaryPipe,unnecessaryPipeChain,unsupportedServiceAccessors|}:\${2|off,warning,error,message,suggestion|}$0",
+ "insertText": "@effect-diagnostics-next-line \${1|anyUnknownInErrorContext,asyncFunction,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,cryptoRandomUUID,cryptoRandomUUIDInEffect,deterministicKeys,duplicatePackage,effectFnIife,effectFnImplicitAny,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalConsole,globalConsoleInEffect,globalDate,globalDateInEffect,globalErrorInEffectCatch,globalErrorInEffectFailure,globalFetch,globalFetchInEffect,globalRandom,globalRandomInEffect,globalTimers,globalTimersInEffect,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,lazyPromiseInEffectSync,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,newPromise,nodeBuiltinImport,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,processEnv,processEnvInEffect,redundantSchemaTagIdentifier,returnEffectInGen,runEffectInsideEffect,schemaStructWithTag,schemaSyncInEffect,schemaUnionOfLiterals,scopeInLayerEffect,serviceNotAsClass,strictBooleanExpressions,strictEffectProvide,tryCatchInEffectGen,unknownInEffectCatch,unnecessaryEffectGen,unnecessaryFailYieldableError,unnecessaryPipe,unnecessaryPipeChain,unsupportedServiceAccessors|}:\${2|off,warning,error,message,suggestion|}$0",
"isSnippet": true,
"kind": "string",
"name": "@effect-diagnostics-next-line",
diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/lazyPromiseInEffectSync.ts.codefixes b/packages/harness-effect-v4/__snapshots__/diagnostics/lazyPromiseInEffectSync.ts.codefixes
new file mode 100644
index 00000000..fca14860
--- /dev/null
+++ b/packages/harness-effect-v4/__snapshots__/diagnostics/lazyPromiseInEffectSync.ts.codefixes
@@ -0,0 +1,4 @@
+lazyPromiseInEffectSync_skipNextLine from 325 to 349
+lazyPromiseInEffectSync_skipFile from 325 to 349
+lazyPromiseInEffectSync_skipNextLine from 464 to 482
+lazyPromiseInEffectSync_skipFile from 464 to 482
\ No newline at end of file
diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/lazyPromiseInEffectSync.ts.output b/packages/harness-effect-v4/__snapshots__/diagnostics/lazyPromiseInEffectSync.ts.output
new file mode 100644
index 00000000..9b2bb525
--- /dev/null
+++ b/packages/harness-effect-v4/__snapshots__/diagnostics/lazyPromiseInEffectSync.ts.output
@@ -0,0 +1,5 @@
+() => Promise.resolve(1)
+8:46 - 8:70 | 0 | This `Effect.sync` thunk returns a Promise. Use `Effect.promise` or `Effect.tryPromise` to represent async work. effect(lazyPromiseInEffectSync)
+
+() => promiseValue
+11:44 - 11:62 | 0 | This `Effect.sync` thunk returns a Promise. Use `Effect.promise` or `Effect.tryPromise` to represent async work. effect(lazyPromiseInEffectSync)
\ No newline at end of file
diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/lazyPromiseInEffectSync_preview.ts.codefixes b/packages/harness-effect-v4/__snapshots__/diagnostics/lazyPromiseInEffectSync_preview.ts.codefixes
new file mode 100644
index 00000000..54caa6c2
--- /dev/null
+++ b/packages/harness-effect-v4/__snapshots__/diagnostics/lazyPromiseInEffectSync_preview.ts.codefixes
@@ -0,0 +1,2 @@
+lazyPromiseInEffectSync_skipNextLine from 152 to 176
+lazyPromiseInEffectSync_skipFile from 152 to 176
\ No newline at end of file
diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/lazyPromiseInEffectSync_preview.ts.output b/packages/harness-effect-v4/__snapshots__/diagnostics/lazyPromiseInEffectSync_preview.ts.output
new file mode 100644
index 00000000..5a90937f
--- /dev/null
+++ b/packages/harness-effect-v4/__snapshots__/diagnostics/lazyPromiseInEffectSync_preview.ts.output
@@ -0,0 +1,2 @@
+() => Promise.resolve(1)
+5:35 - 5:59 | 0 | This `Effect.sync` thunk returns a Promise. Use `Effect.promise` or `Effect.tryPromise` to represent async work. effect(lazyPromiseInEffectSync)
\ No newline at end of file
diff --git a/packages/harness-effect-v4/examples/diagnostics/lazyPromiseInEffectSync.ts b/packages/harness-effect-v4/examples/diagnostics/lazyPromiseInEffectSync.ts
new file mode 100644
index 00000000..790f7229
--- /dev/null
+++ b/packages/harness-effect-v4/examples/diagnostics/lazyPromiseInEffectSync.ts
@@ -0,0 +1,20 @@
+// @effect-diagnostics lazyPromiseInEffectSync:warning
+import { Effect } from "effect"
+
+declare const promiseValue: Promise
+declare const thenableValue: { then: (onFulfilled: (value: number) => unknown) => unknown }
+
+// Should trigger - Promise returned from Effect.sync
+export const fromPromiseResolve = Effect.sync(() => Promise.resolve(1))
+
+// Should trigger - declared Promise type returned from Effect.sync
+export const fromPromiseValue = Effect.sync(() => promiseValue)
+
+// Should NOT trigger - sync value
+export const fromValue = Effect.sync(() => 1)
+
+// Should NOT trigger - thenable but not Promise
+export const fromThenable = Effect.sync(() => thenableValue)
+
+// Should NOT trigger - already using the async constructor
+export const fromEffectPromise = Effect.promise(() => Promise.resolve(1))
diff --git a/packages/harness-effect-v4/examples/diagnostics/lazyPromiseInEffectSync_preview.ts b/packages/harness-effect-v4/examples/diagnostics/lazyPromiseInEffectSync_preview.ts
new file mode 100644
index 00000000..6ba4ca4d
--- /dev/null
+++ b/packages/harness-effect-v4/examples/diagnostics/lazyPromiseInEffectSync_preview.ts
@@ -0,0 +1,5 @@
+// @effect-diagnostics *:off
+// @effect-diagnostics lazyPromiseInEffectSync:warning
+import { Effect } from "effect"
+
+export const preview = Effect.sync(() => Promise.resolve(1))
diff --git a/packages/language-service/src/core/TypeParser.ts b/packages/language-service/src/core/TypeParser.ts
index e8efd2c6..16c5d224 100644
--- a/packages/language-service/src/core/TypeParser.ts
+++ b/packages/language-service/src/core/TypeParser.ts
@@ -224,6 +224,10 @@ export interface TypeParser {
type: ts.Type,
atLocation: ts.Node
) => Nano.Nano<{ type: ts.Type }, TypeParserIssue>
+ promiseType: (
+ type: ts.Type,
+ atLocation: ts.Node
+ ) => Nano.Nano
extendsEffectService: (atLocation: ts.ClassDeclaration) => Nano.Nano<
{
className: ts.Identifier
@@ -1861,6 +1865,28 @@ export function make(
(type) => type
)
+ const promiseType = Nano.cachedBy(
+ function(
+ type: ts.Type,
+ atLocation: ts.Node
+ ) {
+ const promiseSymbol = typeChecker.resolveName("Promise", undefined, ts.SymbolFlags.Type, false)
+ if (!promiseSymbol) return typeParserIssue("global Promise type not found", type, atLocation)
+ const globalPromiseType = typeChecker.getDeclaredTypeOfSymbol(promiseSymbol)
+
+ if (
+ type === globalPromiseType ||
+ ("target" in type && (type as ts.TypeReference).target === globalPromiseType) ||
+ typeChecker.isTypeAssignableTo(type, globalPromiseType)
+ ) {
+ return Nano.succeed(type)
+ }
+ return typeParserIssue("type is not a Promise", type, atLocation)
+ },
+ "TypeParser.promiseType",
+ (type) => type
+ )
+
const extendsSchemaClass = Nano.cachedBy(
Nano.fn("TypeParser.extendsSchemaClass")(function*(
atLocation: ts.ClassDeclaration
@@ -3184,6 +3210,7 @@ export function make(
singleArgCall,
scopeType,
promiseLike,
+ promiseType,
extendsEffectTag,
extendsEffectService,
extendsServiceMapService,
diff --git a/packages/language-service/src/diagnostics.ts b/packages/language-service/src/diagnostics.ts
index 156f887c..f6b98599 100644
--- a/packages/language-service/src/diagnostics.ts
+++ b/packages/language-service/src/diagnostics.ts
@@ -33,6 +33,7 @@ import { globalTimersInEffect } from "./diagnostics/globalTimersInEffect.js"
import { importFromBarrel } from "./diagnostics/importFromBarrel.js"
import { instanceOfSchema } from "./diagnostics/instanceOfSchema.js"
import { layerMergeAllWithDependencies } from "./diagnostics/layerMergeAllWithDependencies.js"
+import { lazyPromiseInEffectSync } from "./diagnostics/lazyPromiseInEffectSync.js"
import { leakingRequirements } from "./diagnostics/leakingRequirements.js"
import { missedPipeableOpportunity } from "./diagnostics/missedPipeableOpportunity.js"
import { missingEffectContext } from "./diagnostics/missingEffectContext.js"
@@ -90,6 +91,7 @@ export const diagnostics = [
effectInFailure,
missingStarInYieldEffectGen,
newPromise,
+ lazyPromiseInEffectSync,
unnecessaryEffectGen,
unnecessaryFailYieldableError,
missingReturnYieldStar,
diff --git a/packages/language-service/src/diagnostics/lazyPromiseInEffectSync.ts b/packages/language-service/src/diagnostics/lazyPromiseInEffectSync.ts
new file mode 100644
index 00000000..58feced9
--- /dev/null
+++ b/packages/language-service/src/diagnostics/lazyPromiseInEffectSync.ts
@@ -0,0 +1,65 @@
+import type ts from "typescript"
+import * as LSP from "../core/LSP.js"
+import * as Nano from "../core/Nano.js"
+import * as TypeCheckerApi from "../core/TypeCheckerApi.js"
+import * as TypeCheckerUtils from "../core/TypeCheckerUtils.js"
+import * as TypeParser from "../core/TypeParser.js"
+import * as TypeScriptApi from "../core/TypeScriptApi.js"
+
+export const lazyPromiseInEffectSync = LSP.createDiagnostic({
+ name: "lazyPromiseInEffectSync",
+ code: 70,
+ description: "Warns when Effect.sync lazily returns a Promise instead of using an async Effect constructor",
+ group: "antipattern",
+ severity: "warning",
+ fixable: false,
+ supportedEffect: ["v3", "v4"],
+ apply: Nano.fn("lazyPromiseInEffectSync.apply")(function*(sourceFile, report) {
+ const ts = yield* Nano.service(TypeScriptApi.TypeScriptApi)
+ const typeChecker = yield* Nano.service(TypeCheckerApi.TypeCheckerApi)
+ const typeCheckerUtils = yield* Nano.service(TypeCheckerUtils.TypeCheckerUtils)
+ const typeParser = yield* Nano.service(TypeParser.TypeParser)
+
+ const nodeToVisit: Array = []
+ const appendNodeToVisit = (node: ts.Node) => {
+ nodeToVisit.push(node)
+ return undefined
+ }
+ ts.forEachChild(sourceFile, appendNodeToVisit)
+
+ while (nodeToVisit.length > 0) {
+ const node = nodeToVisit.shift()!
+ ts.forEachChild(node, appendNodeToVisit)
+
+ if (!ts.isCallExpression(node)) continue
+
+ const isSyncCall = yield* Nano.orUndefined(
+ typeParser.isNodeReferenceToEffectModuleApi("sync")(node.expression)
+ )
+ if (!isSyncCall) continue
+
+ const lazyArg = node.arguments[0]
+ if (!lazyArg) continue
+
+ const lazyArgType = typeCheckerUtils.getTypeAtLocation(lazyArg)
+ if (!lazyArgType) continue
+
+ const entries = typeCheckerUtils.unrollUnionMembers(lazyArgType).flatMap((member) =>
+ typeChecker.getSignaturesOfType(member, ts.SignatureKind.Call).map((signature) =>
+ typeParser.promiseType(typeChecker.getReturnTypeOfSignature(signature), lazyArg)
+ )
+ )
+ if (entries.length === 0) continue
+
+ const promiseReturn = yield* Nano.orUndefined(Nano.firstSuccessOf(entries))
+ if (!promiseReturn) continue
+
+ report({
+ location: lazyArg,
+ messageText:
+ "This `Effect.sync` thunk returns a Promise. Use `Effect.promise` or `Effect.tryPromise` to represent async work.",
+ fixes: []
+ })
+ }
+ })
+})
diff --git a/packages/language-service/src/metadata.json b/packages/language-service/src/metadata.json
index d9cd3dde..149f5826 100644
--- a/packages/language-service/src/metadata.json
+++ b/packages/language-service/src/metadata.json
@@ -571,6 +571,27 @@
]
}
},
+ {
+ "name": "lazyPromiseInEffectSync",
+ "group": "antipattern",
+ "description": "Warns when Effect.sync lazily returns a Promise instead of using an async Effect constructor",
+ "defaultSeverity": "warning",
+ "fixable": false,
+ "supportedEffect": [
+ "v3",
+ "v4"
+ ],
+ "preview": {
+ "sourceText": "import { Effect } from \"effect\"\n\nexport const preview = Effect.sync(() => Promise.resolve(1))\n",
+ "diagnostics": [
+ {
+ "start": 68,
+ "end": 92,
+ "text": "This `Effect.sync` thunk returns a Promise. Use `Effect.promise` or `Effect.tryPromise` to represent async work. effect(lazyPromiseInEffectSync)"
+ }
+ ]
+ }
+ },
{
"name": "leakingRequirements",
"group": "antipattern",
diff --git a/schema.json b/schema.json
index d4c1d5dc..ffee4c09 100644
--- a/schema.json
+++ b/schema.json
@@ -2579,6 +2579,18 @@
"default": "warning",
"description": "Detects interdependencies in Layer.mergeAll calls where one layer provides a service that another layer requires Default severity: warning."
},
+ "lazyPromiseInEffectSync": {
+ "type": "string",
+ "enum": [
+ "off",
+ "error",
+ "warning",
+ "message",
+ "suggestion"
+ ],
+ "default": "warning",
+ "description": "Warns when Effect.sync lazily returns a Promise instead of using an async Effect constructor Default severity: warning."
+ },
"leakingRequirements": {
"type": "string",
"enum": [