Skip to content

Commit

Permalink
Improve TypeScript diagnostics for Effect (#56)
Browse files Browse the repository at this point in the history
  • Loading branch information
mattiamanzati authored Mar 1, 2025
1 parent 19e5a77 commit 5b2b27c
Show file tree
Hide file tree
Showing 27 changed files with 1,125 additions and 228 deletions.
73 changes: 73 additions & 0 deletions .changeset/wet-ducks-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
"@effect/language-service": minor
---

Add support for Effect diagnostics

With this release of the language service plugin, we aim to improve the overall Effect experience by providing additional diagnostics that tries to fix misleading or hard to read TypeScript errors.

All of the diagnostics provided by the language service are available only in editor-mode, that means that they won't show up when using tsc.

Diagnostics are enabled by default, but you can opt-out of them by changing the language service configuration and provide diagnostics: false.

```json
{
"plugins": [
{
"name": "@effect/language-service",
"diagnostics": false
}
]
}
```

Please report any false positive or missing diagnostic you encounter over the Github repository.

## Missing Errors and Services in Effects

Additionally to the standard TypeScript error that may be cryptic at first:

```
Argument of type 'Effect<number, never, ServiceB | ServiceA | ServiceC>' is not assignable to parameter of type 'Effect<number, never, ServiceB | ServiceA>' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
Type 'ServiceB | ServiceA | ServiceC' is not assignable to type 'ServiceB | ServiceA'.
Type 'ServiceC' is not assignable to type 'ServiceB | ServiceA'.
Type 'ServiceC' is not assignable to type 'ServiceA'.
Types of property 'a' are incompatible.
Type '3' is not assignable to type '1'.ts(2379)
```

you'll now receive an additional error:

```
Missing 'ServiceC' in the expected Effect context.
```

## Floating Effect

In some situation you may not receive any compile error at all, but that's because you may have forgot to yield your effects inside gen!

Floating Effects that are not assigned to a variable will be reported into the Effect diagnostics.

```ts
Effect.runPromise(
Effect.gen(function* () {
Effect.sync(() => console.log("Hello!"));
// ^- Effect must be yielded or assigned to a variable.
})
);
```

## Used yield instead of yield\*

Similarly, yield instead of yield\* won't result in a type error by itself, but is not the intended usage.

This yield will be reported in the effect diagnostics.

```ts
Effect.runPromise(
Effect.gen(function* () {
yield Effect.sync(() => console.log("Hello!"));
// ^- When yielding Effects inside Effect.gen, you should use yield* instead of yield.
})
);
```
13 changes: 13 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Attach to TS Server",
"type": "node",
"request": "attach",
"protocol": "inspector",
"port": 5667,
"sourceMaps": true,
}
]
}
12 changes: 12 additions & 0 deletions examples/diagnostics/floatingEffect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import * as Effect from "effect/Effect"

const noError = Effect.succeed(1)

Effect.succeed("floating")

Effect.never

Effect.runPromise(Effect.gen(function*(){
const thisIsFine = Effect.succeed(1)
Effect.never
}))
30 changes: 30 additions & 0 deletions examples/diagnostics/missingEffectContext_callExpression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as Effect from "effect/Effect"

class ServiceA extends Effect.Service<ServiceB>()("ServiceA", {
succeed: { a: 1}
}){}

class ServiceB extends Effect.Service<ServiceB>()("ServiceB", {
succeed: { a: 2}
}){}

class ServiceC extends Effect.Service<ServiceB>()("ServiceC", {
succeed: { a: 3}
}){}

declare const effectWithServices: Effect.Effect<number, never, ServiceA | ServiceB | ServiceC >

function testFn(effect: Effect.Effect<number>){
return effect
}

// @ts-expect-error
testFn(effectWithServices)

function testFnWithServiceAB(effect: Effect.Effect<number, never, ServiceA | ServiceB>){
return effect
}

// @ts-expect-error
testFnWithServiceAB(effectWithServices)

38 changes: 38 additions & 0 deletions examples/diagnostics/missingEffectContext_plainAssignment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import * as Context from "effect/Context"
import * as Effect from "effect/Effect"

class ServiceA extends Effect.Service<ServiceB>()("ServiceA", {
succeed: { a: 1}
}){}

class ServiceB extends Effect.Service<ServiceB>()("ServiceB", {
succeed: { a: 2}
}){}

class ServiceC extends Effect.Service<ServiceB>()("ServiceC", {
succeed: { a: 3}
}){}

declare const effectWithServices: Effect.Effect<number, never, ServiceA | ServiceB | ServiceC >

export const noError: Effect.Effect<number> = Effect.succeed(1)

// @ts-expect-error
export const missingAllServices: Effect.Effect<number> = effectWithServices

// @ts-expect-error
export const missingServiceC: Effect.Effect<number, never, ServiceA | ServiceB> = effectWithServices

export interface EffectSubtyping<A> extends Effect.Effect<A, never, ServiceA | ServiceB> {}

// @ts-expect-error
export const missingServiceCWithSubtyping: EffectSubtyping<number> = effectWithServices

export function missingServiceWithGenericType<A>(service: A){
// @ts-expect-error
const missingServiceA: Effect.Effect<Context.Context<A>> = Effect.context<A>()
return missingServiceA
}

// @ts-expect-error
const _ = effectWithServices satisfies Effect.Effect<number, never, never>
26 changes: 26 additions & 0 deletions examples/diagnostics/missingEffectContext_returnSignature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as Effect from "effect/Effect"

class ServiceA extends Effect.Service<ServiceB>()("ServiceA", {
succeed: { a: 1}
}){}

class ServiceB extends Effect.Service<ServiceB>()("ServiceB", {
succeed: { a: 2}
}){}

class ServiceC extends Effect.Service<ServiceB>()("ServiceC", {
succeed: { a: 3}
}){}

declare const effectWithServices: Effect.Effect<number, never, ServiceA | ServiceB | ServiceC >

export function testFn(): Effect.Effect<number> {
// @ts-expect-error
return effectWithServices
}

// @ts-expect-error
export const conciseBody: () => Effect.Effect<number> = () => effectWithServices

// @ts-expect-error
export const conciseBodyMissingServiceC: () => Effect.Effect<number, never, ServiceA | ServiceB> = () => effectWithServices
30 changes: 30 additions & 0 deletions examples/diagnostics/missingEffectError_callExpression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as Effect from "effect/Effect"
import * as Data from "effect/Data"

class ErrorA extends Data.Error<{
a: 1
}>{}

class ErrorB extends Data.Error<{
a: 2
}>{}

class ErrorC extends Data.Error<{
a: 3
}>{}

declare const effectWithErrors: Effect.Effect<number, ErrorA | ErrorB | ErrorC>

function testFn(effect: Effect.Effect<number>){
return effect
}

// @ts-expect-error
testFn(effectWithErrors)

function testFnWithServiceAB(effect: Effect.Effect<number, ErrorA | ErrorB>){
return effect
}

// @ts-expect-error
testFnWithServiceAB(effectWithErrors)
38 changes: 38 additions & 0 deletions examples/diagnostics/missingEffectError_plainAssignment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import * as Effect from "effect/Effect"
import * as Data from "effect/Data"

class ErrorA extends Data.Error<{
a: 1
}>{}

class ErrorB extends Data.Error<{
a: 2
}>{}

class ErrorC extends Data.Error<{
a: 3
}>{}

declare const effectWithErrors: Effect.Effect<number, ErrorA | ErrorB | ErrorC>

export const noError: Effect.Effect<number> = Effect.succeed(1)

// @ts-expect-error
export const missingAllErrors: Effect.Effect<number> = effectWithErrors

// @ts-expect-error
export const missingErrorC: Effect.Effect<number, ErrorA | ErrorB> = effectWithErrors

export interface EffectSubtyping<A> extends Effect.Effect<A, ErrorA | ErrorB> {}

// @ts-expect-error
export const missingErrorCWithSubtyping: EffectSubtyping<number> = effectWithErrors

export function missingErrorWithGenericType<A>(error: A){
// @ts-expect-error
const missingErrorA: Effect.Effect<never> = Effect.fail(error)
return missingErrorA
}

// @ts-expect-error
const _ = effectWithErrors satisfies Effect.Effect<number, never, never>
27 changes: 27 additions & 0 deletions examples/diagnostics/missingEffectError_returnSignature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import * as Effect from "effect/Effect"
import * as Data from "effect/Data"

class ErrorA extends Data.Error<{
a: 1
}>{}

class ErrorB extends Data.Error<{
a: 2
}>{}

class ErrorC extends Data.Error<{
a: 3
}>{}

declare const effectWithErrors: Effect.Effect<number, ErrorA | ErrorB | ErrorC>

export function testFn(): Effect.Effect<number> {
// @ts-expect-error
return effectWithErrors
}

// @ts-expect-error
export const conciseBody: () => Effect.Effect<number> = () => effectWithErrors

// @ts-expect-error
export const conciseBodyMissingServiceC: () => Effect.Effect<number, ErrorA | ErrorB> = () => effectWithErrors
17 changes: 17 additions & 0 deletions examples/diagnostics/missingStarInYieldEffectGen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import * as Effect from "effect/Effect"

const noError = Effect.gen(function*(){
yield* Effect.succeed(1)
})

// @ts-expect-error
const missingStarInYield = Effect.gen(function*(){
yield Effect.succeed(1)
})

const missingStarInInnerYield = Effect.gen(function*(){
// @ts-expect-error
yield* Effect.gen(function*(){
yield Effect.succeed(1)
})
})
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"clean": "rimraf dist docs coverage .tsbuildinfo",
"lint": "eslint src test",
"lint-fix": "eslint src test --fix",
Expand Down
35 changes: 33 additions & 2 deletions src/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/
import type * as Option from "effect/Option"
import type ts from "typescript"
import type * as AST from "./utils/AST.js"
import type * as TSAPI from "./utils/TSAPI.js"

/**
* @since 1.0.0
Expand All @@ -12,7 +12,7 @@ import type * as AST from "./utils/AST.js"
export interface RefactorDefinition {
name: string
description: string
apply: (ts: AST.TypeScriptApi, program: ts.Program, options: PluginOptions) => (
apply: (ts: TSAPI.TypeScriptApi, program: ts.Program, options: PluginOptions) => (
sourceFile: ts.SourceFile,
textRange: ts.TextRange
) => Option.Option<ApplicableRefactorDefinition>
Expand All @@ -36,9 +36,40 @@ export function createRefactor(definition: RefactorDefinition) {
return definition
}

/**
* @since 1.0.0
* @category plugin
*/
export interface DiagnosticDefinition {
code: number
apply: (ts: TSAPI.TypeScriptApi, program: ts.Program, options: PluginOptions) => (
sourceFile: ts.SourceFile,
standardDiagnostic: ReadonlyArray<ts.Diagnostic>
) => Array<ApplicableDiagnosticDefinition>
}

/**
* @since 1.0.0
* @category plugin
*/
export interface ApplicableDiagnosticDefinition {
node: ts.Node
category: ts.DiagnosticCategory
messageText: string
}

/**
* @since 1.0.0
* @category plugin
*/
export function createDiagnostic(definition: DiagnosticDefinition) {
return definition
}

/**
* @since 1.0.0
* @category plugin
*/
export interface PluginOptions {
diagnostics: boolean
}
17 changes: 17 additions & 0 deletions src/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* @since 1.0.0
*/
import { floatingEffect } from "./diagnostics/floatingEffect.js"
import { missingEffectContext } from "./diagnostics/missingEffectContext.js"
import { missingEffectError } from "./diagnostics/missingEffectError.js"
import { missingStarInYieldEffectGen } from "./diagnostics/missingStarInYieldEffectGen.js"

/**
* @since 1.0.0
*/
export const diagnostics = {
missingEffectContext,
missingEffectError,
floatingEffect,
missingStarInYieldEffectGen
}
Loading

0 comments on commit 5b2b27c

Please sign in to comment.