Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use literal strings for service tags #1955

Closed
wants to merge 7 commits into from
Closed

Conversation

mikearnaldi
Copy link
Member

@mikearnaldi mikearnaldi commented Jan 19, 2024

Requesting early review for feedback before refactoring the rest.

This PR standardises tags to use string literals, basically a service is now represented by a tag like:

const UserRepo = Context.Tag("UserRepo")<UserRepo>()

and will be identified with the literal "UseRepo" in types.

This solves in one shot multiple issues, assignability of potentially similar types (uniqueness at the type level) and automatically makes tags global, meaning instantiating multiple times the same tag will lead to the same instance.

Debuggability also gains from having human readable names for every service in context and arguably the types are overall much simpler.

There is a degree of awkwardness in layers that now look like Layer<"Database", never, "UserRepo" | "TodoRepo"> but I quite like it

Copy link

changeset-bot bot commented Jan 19, 2024

🦋 Changeset detected

Latest commit: 632cdac

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
effect Minor
@effect/cli Major
@effect/experimental Major
@effect/opentelemetry Major
@effect/platform-browser Major
@effect/platform-bun Major
@effect/platform-node Major
@effect/platform Major
@effect/printer-ansi Major
@effect/printer Major
@effect/rpc-http-node Major
@effect/rpc-http Major
@effect/rpc-nextjs Major
@effect/rpc-workers Major
@effect/rpc Major
@effect/schema Major
@effect/typeclass Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions bot changed the base branch from main to next-minor January 19, 2024 11:22
@mikearnaldi mikearnaldi added the breaking Breaking change label Jan 19, 2024
@patroza
Copy link
Member

patroza commented Jan 19, 2024

I suppose this was one way of getting rid of some of the X.X :D bravo!
It certainly cleans up this Id+key mechanical requirements, especially when the recommended approach was going to be Service is Id, and ServiceShape is going to be the service interface.
I guess getting used to the literals will just take a bit of time and it will no longer feel awkward.

will need to play with it for a bit for more meaningful feedback :)

@patroza
Copy link
Member

patroza commented Jan 19, 2024

While name collision is a thing. Do we consider it a feature or a downgrade, that you will no longer be warned when you have multiple versions of libraries?
e.g having 2x platform, if you provide Server from version X, and use Server from version Y, you will get a mismatch because symbols don't match.

@TylorS
Copy link
Contributor

TylorS commented Jan 19, 2024

I definitely favor this overall idea. My app at work mostly uses string-literal identifiers, it's honestly easier to read most of the time in the Context parameter, and I utilize app-name/feature-name/service-name as a pattern to avoid collisions. If I get a type error saying "x is missing", it also tells me exactly which part of my application is contributing the issue.

I dunno if it's just familiarity, but I honestly prefer the order of parameters the opposite direction Tag<Service>()("ID") as it places what's actually important first. e.g. When I need a service, I usually think about what it does first, not what it will be identified as, that's secondary. Second to that, this makes no sense to do

const a = Tag("Id")
const b = a<number>()
const c = a<string>()

while this does make sense

const a = Tag<number>()
const b = a("foo")
const c = a("bar")

Not something that I think Effect needs, it's fairly opinionated on my end, but I have a Context library that builds atop of effect/Context, and I also have support for creating opaque identifiers despite them really just being strings - https://github.com/TylorS/typed/blob/development/packages/context/test/context.ts#L179. Here the Identifier will show up as FooBarResolver instead of just "FooBar". I don't use it much in my tests, but every there is a string identifier, it also supports this class factory function, and extracts the identifier using InstanceOf

Copy link
Member

@tim-smart tim-smart left a comment

Choose a reason for hiding this comment

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

I like it, it simplifies a few things.

I agree that the arg order should switch.

@@ -38,27 +37,27 @@ export const serve = dual<
(): <R, E>(
httpApp: App.Default<R, E>
) => Layer.Layer<
Server.Server | Exclude<R, ServerRequest.ServerRequest | Scope.Scope>,
Server.Server | Exclude<R, ServerRequest.ServerRequest | "Scope">,
Copy link
Member

Choose a reason for hiding this comment

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

Few updates needed in this file

readonly tag: Context.Tag<SchemaStore<A>, SchemaStore<A>>
readonly layer: Layer.Layer<KeyValueStore, never, SchemaStore<A>>
} = internal.layerSchema
export const layerSchema: <I, A, K extends string>(
Copy link
Member

Choose a reason for hiding this comment

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

Definitely improves the service factory pattern.

@TylorS
Copy link
Contributor

TylorS commented Jan 22, 2024

I've been thinking about this a bit more, and I'm less sure this really is something we want to force upon users. I'd say requiring the string literals to make them global and identifiable could be a good thing still, but less sure about them being forced as the identifiers in the Context type param.

While I use them in my apps quite a lot, I think the key difference there is I rely almost exclusively on inference there except in more rare cases of recursion. However, in the case of the "core" services that the effect package itself offers, there's a LOT of times where I create library code which explicitly mention them so type-inference is correct and/or not infinitely deep.

In those circumstances I quite like only needing to import, say Scope and utilize this for the type and the service from a DX perspective. I also like that these values are intrinsically linked and that an import marks the relationship as to what "Scope" I'm utilizing and where the definitive definition comes from and I can jump right there in my editor.

I feel like I can't articulate it fully, but I also feel like there's going to be some annoyance with refactoring these string literals as they won't be connected to any specific TS symbol without some more convention, maybe something like this

export interface Scope { ... }

// Don't love this convention because now typing "Tag" will autocomplete lots and lots of packages
export const Tag = Context.Tag<Scope>()("Scope")
export type Tag = Context.Tag.Identifier<typeof Tag>

Maybe these aren't so bad to live with, but I figured I'd at least share some "cons" of this approach for more discussion before we get too far.

@tim-smart
Copy link
Member

Don't love this convention because now typing "Tag" will autocomplete lots and lots of packages

This change won't include the renaming of tags, so the Scope tag will still be called Scope.

But I agree the biggest con of this change is losing jump to definition for identifiers - but it is something I hardly ever do myself.

@github-actions github-actions bot force-pushed the next-minor branch 2 times, most recently from 07c1921 to 40aadfb Compare January 22, 2024 21:00
Base automatically changed from next-minor to main January 22, 2024 21:01
@github-actions github-actions bot changed the base branch from main to next-minor January 22, 2024 21:05
@TylorS
Copy link
Contributor

TylorS commented Jan 22, 2024

@tim-smart I don't too often either, but I think we've likely written, touched, or at least read all of the services we'd ever possibly utilize. This isn't true for people just getting started though, I'm actually onboarding a new team member, and I can say "following the TS types" is a pretty common pattern for learning our codebase

@tim-smart
Copy link
Member

I can say "following the TS types" is a pretty common pattern for learning our codebase

I still use jump to definition for the tags themselves, and on the actual service implementation type, but rarely for tag identifiers.

We are only losing one of the three here, so the impact isn't massive in my opinion.

@TylorS
Copy link
Contributor

TylorS commented Jan 22, 2024

The convenience now is that they're intrinsically linked for all the Effect services. It changes after this lands though, finding the tag is all that much harder.

Take the following, contrived for brevity, example

import * as Effect from 'effect/Effect'

function foo<R, E, A>(effect: Effect.Effect<R, E, A>): Effect.Effect<"Scope" | R , E, A> {
  return Effect.gen(function*(_) {
    yield* _(Effect.addFinalizer(someFinalizer))
    return yield* _(effect)
  })
}

There's 0 reference to the Scope Tag or Service, only the identifier. This function no longer has any connection to effect/Scope, which is the same behavior as using inference, but is now the case even with explicit annotations.

Then if you're actually curious about what the heck a Scope is you might hover over Effect.addFinalizer and see it also contributes this "Scope" type, so you go to its reference and all you'll see is this in the .ts files

export const addFinalizer: <R, X>(
  finalizer: (exit: Exit.Exit<unknown, unknown>) => Effect<R, never, X>
) => Effect<R | "Scope", never, void> = fiberRuntime.addFinalizer

And you still won't have any idea what/where Scope comes from. You might follow fiberRuntime.addFinalizer the same way

/* @internal */
export const addFinalizer = <R, X>(
  finalizer: (exit: Exit.Exit<unknown, unknown>) => Effect.Effect<R, never, X>
): Effect.Effect<R | "Scope", never, void> =>
  core.withFiberRuntime(
    (runtime) => {
      const acquireRefs = runtime.getFiberRefs()
      const acquireFlags = runtime._runtimeFlags
      return core.flatMap(scope, (scope) =>
        core.scopeAddFinalizerExit(scope, (exit) =>
          core.withFiberRuntime((runtimeFinalizer) => {
            const preRefs = runtimeFinalizer.getFiberRefs()
            const preFlags = runtimeFinalizer._runtimeFlags
            const patchRefs = FiberRefsPatch.diff(preRefs, acquireRefs)
            const patchFlags = _runtimeFlags.diff(preFlags, acquireFlags)
            const inverseRefs = FiberRefsPatch.diff(acquireRefs, preRefs)
            runtimeFinalizer.setFiberRefs(
              FiberRefsPatch.patch(patchRefs, runtimeFinalizer.id(), acquireRefs)
            )

            return ensuring(
              core.withRuntimeFlags(finalizer(exit) as Effect.Effect<never, never, X>, patchFlags),
              core.sync(() => {
                runtimeFinalizer.setFiberRefs(
                  FiberRefsPatch.patch(inverseRefs, runtimeFinalizer.id(), runtimeFinalizer.getFiberRefs())
                )
              })
            )
          })))
    }
  )

Now you're forced to read this internal implementation to find the scope Effect, which then leads to scopeTag, and finally you'll see the first instance of the Service Scope.

I definitely find this subpar for someone who learns using the TS types. Especially as the ecosystem builds up more and more and there will only be larger layers of indirection between the API a developer is trying to learn and the Effect services they'll inevitably utilize

@tim-smart
Copy link
Member

I think Scope isn't a great example, as even now the jump to definition for the identifier / service isn't great and you have to rely on the jsdoc to understand what a function does.

I agree that losing jump to definition for service identifiers isn't great, but it is a small loss considering the gains in other areas.

@mikearnaldi
Copy link
Member Author

While name collision is a thing. Do we consider it a feature or a downgrade, that you will no longer be warned when you have multiple versions of libraries? e.g having 2x platform, if you provide Server from version X, and use Server from version Y, you will get a mismatch because symbols don't match.

Not sure that this was a feature, version checks shouldn't really be lib level, duplicate checks should be done at install level

@mikearnaldi
Copy link
Member Author

The convenience now is that they're intrinsically linked for all the Effect services. It changes after this lands though, finding the tag is all that much harder.

Take the following, contrived for brevity, example

import * as Effect from 'effect/Effect'

function foo<R, E, A>(effect: Effect.Effect<R, E, A>): Effect.Effect<"Scope" | R , E, A> {
  return Effect.gen(function*(_) {
    yield* _(Effect.addFinalizer(someFinalizer))
    return yield* _(effect)
  })
}

There's 0 reference to the Scope Tag or Service, only the identifier. This function no longer has any connection to effect/Scope, which is the same behavior as using inference, but is now the case even with explicit annotations.

Then if you're actually curious about what the heck a Scope is you might hover over Effect.addFinalizer and see it also contributes this "Scope" type, so you go to its reference and all you'll see is this in the .ts files

export const addFinalizer: <R, X>(
  finalizer: (exit: Exit.Exit<unknown, unknown>) => Effect<R, never, X>
) => Effect<R | "Scope", never, void> = fiberRuntime.addFinalizer

And you still won't have any idea what/where Scope comes from. You might follow fiberRuntime.addFinalizer the same way

/* @internal */
export const addFinalizer = <R, X>(
  finalizer: (exit: Exit.Exit<unknown, unknown>) => Effect.Effect<R, never, X>
): Effect.Effect<R | "Scope", never, void> =>
  core.withFiberRuntime(
    (runtime) => {
      const acquireRefs = runtime.getFiberRefs()
      const acquireFlags = runtime._runtimeFlags
      return core.flatMap(scope, (scope) =>
        core.scopeAddFinalizerExit(scope, (exit) =>
          core.withFiberRuntime((runtimeFinalizer) => {
            const preRefs = runtimeFinalizer.getFiberRefs()
            const preFlags = runtimeFinalizer._runtimeFlags
            const patchRefs = FiberRefsPatch.diff(preRefs, acquireRefs)
            const patchFlags = _runtimeFlags.diff(preFlags, acquireFlags)
            const inverseRefs = FiberRefsPatch.diff(acquireRefs, preRefs)
            runtimeFinalizer.setFiberRefs(
              FiberRefsPatch.patch(patchRefs, runtimeFinalizer.id(), acquireRefs)
            )

            return ensuring(
              core.withRuntimeFlags(finalizer(exit) as Effect.Effect<never, never, X>, patchFlags),
              core.sync(() => {
                runtimeFinalizer.setFiberRefs(
                  FiberRefsPatch.patch(inverseRefs, runtimeFinalizer.id(), runtimeFinalizer.getFiberRefs())
                )
              })
            )
          })))
    }
  )

Now you're forced to read this internal implementation to find the scope Effect, which then leads to scopeTag, and finally you'll see the first instance of the Service Scope.

I definitely find this subpar for someone who learns using the TS types. Especially as the ecosystem builds up more and more and there will only be larger layers of indirection between the API a developer is trying to learn and the Effect services they'll inevitably utilize

Not sure why a user should care about where the Scope tag comes from, it's just an "internal" from the user perspective, in the Effect module you have some functions that require "Scope" and some that provides "Scope" like Effect.scoped also a quick search on the docs can yield the result. As of users learning from types imho it never happens, while I agree with you that it should be a good practice only a few actually can, the majority relies on docs.

Curious if we can work around this via lsp

@patroza
Copy link
Member

patroza commented Jan 24, 2024

Would ”effect/Scope” help the user navigate?
LSP plugin sounds fun too cc @mattiamanzati

@Effect-TS Effect-TS deleted a comment from linear bot Jan 29, 2024
@github-actions github-actions bot force-pushed the next-minor branch 10 times, most recently from c057f9a to 62da5e9 Compare January 31, 2024 09:17
@mikearnaldi
Copy link
Member Author

Superseded by: #2028

@mikearnaldi mikearnaldi closed this Feb 2, 2024
@tim-smart tim-smart deleted the feat/literal-tag branch February 11, 2024 21:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
breaking Breaking change next-minor
Projects
Archived in project
Development

Successfully merging this pull request may close these issues.

4 participants