Skip to content

Type improvements#5672

Closed
nemanja-tosic wants to merge 22 commits intofastify:mainfrom
livingspec:type-improvements
Closed

Type improvements#5672
nemanja-tosic wants to merge 22 commits intofastify:mainfrom
livingspec:type-improvements

Conversation

@nemanja-tosic
Copy link
Copy Markdown

@nemanja-tosic nemanja-tosic commented Sep 8, 2024

WIP #5093

As part of the work to update types, we have the following changes in addition to this PR:

The work in this PR provides support for inferring type definitions after a plugin is registered. The idea is to capture the decorators added to request, reply and instance based on the return type.

This behavior is opt in - it is possible to define plugins without returning the resulting instance (or returning a partially inferred instance) which makes the change backwards compatible, but then TypeScript cannot infer the resulting instance and so manual typing is needed. Given examples from the community, we can provide a migration path and improve the approach.

The ultimate goal is compile time safety, so in order to gain that something needs to be traded off.

The work in fastify-static shows how a core plugin can be updated in a way where we know the decorators it provides. The change is limited to type definitions, and so makes it backwards compatible as well.

fastify-plugin should provide an implementation for a plugin factory that results in un-encapsulated plugins. A new function will be added, which makes the change backwards compatible.

The final API can be made according to feedback from the community - whether createPlugin is part of core etc, i am very happy to make changes given feedback.

Checklist

@github-actions github-actions bot added the typescript TypeScript related label Sep 8, 2024
@jean-michelet
Copy link
Copy Markdown
Member

Do you plan to continue @nemanja-tosic?

@nemanja-tosic
Copy link
Copy Markdown
Author

Do you plan to continue @nemanja-tosic?

Hello. Yes, i am planning on adding plugin dependency type support next hopefully some time next week. I do apologize for radio silence since i created the draft...

@jean-michelet
Copy link
Copy Markdown
Member

jean-michelet commented Oct 22, 2024

Thanks for your work.
It's just to know if you are still motivated as I would like to remove module augmentation from the demo as soon as possible.
If you know a way I can help you, let me know.

@nemanja-tosic
Copy link
Copy Markdown
Author

Thanks for your work. It's just to know if you are still motivated as I would like to remove module augmentation from the demo as soon as possible. If you know a way I can help you, let me know.

Hi @jean-michelet. I pushed a change that demos the plugin dependency concept. Where i could use a bit of help is first of does it makes sense the way i have it and i could use help with implementation on the fastify side.

It is necessary to pass plugins as object in order to extract types. So far, the same was achieved by passing strings for plugin names, so that will be a change.

The createPlugin utility i have needs to be implemented in fastify-plugin, in a way that makes this interface possible.

I will continue with updating to latest fastify and looking into the fastify-plugin side of things.

Thanks!

@jean-michelet
Copy link
Copy Markdown
Member

Looking into it.

Copy link
Copy Markdown
Member

@jean-michelet jean-michelet left a comment

Choose a reason for hiding this comment

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

The typing is a bit hard to read so I will have to get into it more seriously.

I love the idea of plugin dependencies, I don't know if we should upgrade fastifyPlugin or create a new utility createPlugin like you did.

2 worries:

  • maybe I'm wrong, but I think you break a lot of stuff right now, I left a couple of comments.
  • it requires a lot of work to use builder api on existing core plugins like you did, don't know if it's even doable:
const plugin1 = createPlugin((instance) =>
  instance
    .decorateReply('testPropSync')
    .decorateReply('testValueSync', 'testValue')
    .decorateReply('testFnSync', () => 12345))

But I am ok to work on updating the core plugins if doable, we need more review on this PR.

Comment on lines +23 to +37
export const pluginWithDependencies = createPlugin((instance) => {
expectType<void>(instance.testPropSync)
expectType<string>(instance.testValueSync)
expectType<number>(instance.testFnSync())

return instance.get('/', (req, res) => {
expectType<void>(req.testPropSync)
expectType<string>(req.testValueSync)
expectType<number>(req.testFnSync())

expectType<void>(res.testPropSync)
expectType<string>(res.testValueSync)
expectType<number>(res.testFnSync())
})
}, { dependencies: [plugin1, plugin2, plugin3] })
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Instance inherit from parent scope, so I think we should add a test for it:

instance.register(childInstance => {
    expectType<void>(childInstance.testPropSync)
    expectType<string>(childInstance.testValueSync)
    expectType<number>(childInstance.testFnSync())
  })

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I have added a test case, thank you for pointing this out.

Comment on lines 10 to +14
export type FastifyPluginCallback<
Options extends FastifyPluginOptions = Record<never, never>,
Server extends RawServerBase = RawServerDefault,
TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault,
Logger extends FastifyBaseLogger = FastifyBaseLogger
> = (
instance: FastifyInstance<Server, RawRequestDefaultExpression<Server>, RawReplyDefaultExpression<Server>, Logger, TypeProvider>,
opts: Options,
done: (err?: Error) => void
) => void
Options extends FastifyPluginOptions = FastifyPluginOptions,
TIn extends AnyFastifyInstance = AnyFastifyInstance,
TOut extends void | AnyFastifyInstance = void | AnyFastifyInstance
> = (instance: TIn, opts: Options, done: (err?: Error) => void) => TOut
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Are you sure it's ok to break things this way? Don't you think it could surprise users?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The question is difficult to answer. What do you mean by "break things this way"?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hi folks! 👋🏼😊 I agree with @jean-michelet, and we should be super careful with such an update, since it looks like a breaking change to me. Considering existing code like:

  import type {
    FastifyBaseLogger,
    FastifyInstance,
    RawReplyDefaultExpression,
    RawRequestDefaultExpression,
    RawServerDefault,
  } from 'fastify';

  import type { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts';

  const dateSchema = {
    $id: 'date',
    type: 'string',
    format: 'date',
  } as const;

  const jsonReferences = [
    ...dateSchema,
    // all of my JSON Open API schema references ...
  ]

  export type JsonTypeProvider = JsonSchemaToTsProvider<{ references: typeof [] }>;

  export type FastifyJsonSchemaInstance = FastifyInstance<
    RawServerDefault,
    RawRequestDefaultExpression<RawServerDefault>,
    RawReplyDefaultExpression<RawServerDefault>,
    FastifyBaseLogger,
    JsonTypeProvider
  >;

This will break the TS build 🤷🏼

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for the review. Can you please provide a full example that i can work against. I have not touched @fastify/type-provider-json-schema-to-ts so i don't know what's in play here.

Thanks

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You can refer to the code snippet above as a test for the change. By applying the updates in this PR, the TS build will fail. With the current fastify version, it build passes 😉

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Alright, i'll set up a reproduction. What error are you seeing, for reference?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It's complicated to reproduce it here, I replicated it in a large codebase.
In general, the FastifyJsonSchemaInstance, that converts a JSON schema to types, no longer works, so the entire project (that relies on it), has the wrong types when accessing the JSON schemas

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You can basically check and compare what is returned (as type) by FastifyJsonSchemaInstance with this change, and what is returned with the current fastify version 😉 You'll see they differ drastically

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Applying the new version of fastify to fastify-type-provider-json-schema-to-ts does cause type tests to fail in that project, due to the difference in FastifyPluginCallback type params. The type declaration

export type FastifyPluginCallbackJsonSchemaToTs<
  Options extends FastifyPluginOptions = Record<never, never>,
  Server extends RawServerBase = RawServerDefault,
  Logger extends FastifyBaseLogger = FastifyBaseLogger,
> = FastifyPluginCallback<Options, Server, JsonSchemaToTsProvider, Logger>;

was in general part of the scope that was supposed to be changed since we wanted to remove module augmentation. I will take a look if an interim solution is possible where FastifyPluginCallback can be preserved for this type of scenario.

RequestType
>

export interface BaseFastifyRequest<
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

FastifyRequest might be imported and customized in many code bases... Are we sure this is a good idea to modify it? Shouldn't we create a new Type for the problem we are solving?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

FastifyRequest has the same interface, when looked from the outside. It was converted the way it was to incorporate Decorators. Previously, FastifyRequest was an interface, and i couldn't add a type from the generics to the interface. So a straightforward refactor was to introduce an intersection type.

From the outside, FastifyRequest shouldn't have changed when it came to properties it had, it should have only gotten a new prop. The change was meant to be backwards compatible. Do you have an example of breakage?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@nemanja-tosic
Copy link
Copy Markdown
Author

  • maybe I'm wrong, but I think you break a lot of stuff right now, I left a couple of comments.

The changes are meant to be backwards compatible to a reasonable degree. There will be some breaking changes, but i'm trying to keep the number down to a minimum. Doing what needs to be done here has certain requirements when it comes to type definitions. I don't see how to be 100% backwards compatible given the fact that previous code could not support what is needed.

  • it requires a lot of work to use builder api on existing core plugins like you did, don't know if it's even doable:

The only way i could thing of to gain compile time safety with TypeScript in this scenario is to use return values to calculate the impact of a certain operation. If a method returns a decorated instance, we can infer things from the return type. If a method returns void, there is little that can be done in the context of compile time checks.

It is possible to do plenty in run time, by asserting things on the fastify instance, but that was not the goal of these changes.

Therefore, the switch to utilizing return values is a must the way i see it. Plugins can opt into this by either returning an instance or returning void in which case we cannot infer what the plugin provides, but things are backwards compatible.

It is always possible to create a type definition that specifies what a plugin provides, but unless a plugin returns a value it is not possible to check whether the plugin does what the value says it should do, in compile time.

@jean-michelet
Copy link
Copy Markdown
Member

jean-michelet commented Oct 27, 2024

It is always possible to create a type definition that specifies what a plugin provides

It's probably the more reasonable for now, if you can provide a working example with FastifyStatic, it's great 👍
But I am not opposed on returning decorated instance on core plugins too, I don't have a large enough vision to know if it's achievable.

Needed for fastify-static poc.
@nemanja-tosic
Copy link
Copy Markdown
Author

nemanja-tosic commented Oct 27, 2024

It's probably the more reasonable for now, if you can provide a working example with FastifyStatic, it's great 👍 But I am not opposed on returning decorated instance on core plugins too, I don't have a large enough vision to know if it's achievable.

Fastify static has a draft PR with the POC. I added the dependencies as well. There are FIXMEs which i am working through.

First point of interest is the declaration of a plugin.

export type FastifyStaticPlugin = UnEncapsulatedPlugin<
  FastifyPluginAsync<
    NonNullable<fastifyStatic.FastifyStaticOptions>,
    AnyFastifyInstance,
    FastifyInstance<any, any, any, any, any, FastifyStaticPluginDecorators>
  >
>

The change is to types only, and the only aim is to provide a valid type definition that contains the decorators. Fastify types should handle properly merging the out instance FastifyInstance<any, any, any, any, any, FastifyStaticPluginDecorators>.

Second point of interest is dependencies

const pluginWithFastifyStaticDependency = createPlugin((instance) =>
  instance.get('/', (req, res) => {
    expectType<FastifyStaticPluginDecorators['reply']['sendFile']>(res.sendFile)
    expectType<FastifyStaticPluginDecorators['reply']['download']>(res.download)
  }), { dependencies: [fastifyStatic] })

expectError(fastify().register(pluginWithFastifyStaticDependency))

fastify().register(fastifyStatic).register(pluginWithFastifyStaticDependency)

The code in fastify is focused on making the calls chainable in a way that provides inference so that you don't have to manually type. Core plugins can be manually annotated, like fastify-static was in this case. When manually annotating the plugin needs to make sure what it returns matches the definition on it's own. When relying on inference, the output must be correct as it's infered.

Let me know what you think!

Also fixes case where options weren't matching when explicitly
definining plugin type.
@jean-michelet
Copy link
Copy Markdown
Member

Let me know what you think!

Looks great!

When do you think your work will be ready?

@jean-michelet
Copy link
Copy Markdown
Member

I hope with some more eyes from the community

I think there is an interest, one of the maintainer shared me your PR, but I don't know if there is a lot of contributors that have advanced knowledges of TypeScript. For me it's a bit hard to read for now and you understand better than me what to do, I think @climba03003 can share a valuable feedback, maybe when your PR will be ready for review.

Anyway, keeping a close eye on it.

@jean-michelet
Copy link
Copy Markdown
Member

jean-michelet commented Oct 29, 2024

Can we support this?

export const deepPluginDependency = createPlugin((instance) =>
  instance
    .decorateReply('deep_dependency_reply', true))

export const pluginDependecy = createPlugin((instance) => instance
  .decorateRequest('dependency_request', true), { dependencies: [deepPluginDependency] })

export const dependantPlugin = createPlugin((instance) => {
  return instance.get('/', (req, res) => {
    expectType<boolean>(req.dependency_request)
    expectType<boolean>(res.deep_dependency_reply)
  })
}, { dependencies: [pluginDependecy] })

@nemanja-tosic
Copy link
Copy Markdown
Author

I was thinking about this scenario, and i think that we should. I will see what needs to be done to get there.

@jean-michelet
Copy link
Copy Markdown
Member

I had tried in the past and I remember the compiler thrown this error:

Type instantiation is excessively deep and possibly infinite.

I think it's possible to ignore it or set an arbitrary depth level.

@nemanja-tosic
Copy link
Copy Markdown
Author

Added support for transitive dependencies.

@nemanja-tosic
Copy link
Copy Markdown
Author

@jean-michelet could you give me a hand with completing the fastify-plugin side of things please?

At this moment i have the following definition which i used throughout the unit tests which currently passes all the tests.

export function createPlugin<
  TPlugin extends FastifyPluginCallback,
  TDependencies extends FastifyDependencies,
  TEnhanced extends ApplyDependencies<TPlugin, TDependencies> = ApplyDependencies<TPlugin, TDependencies>
> (plugin: TEnhanced, options?: { dependencies?: TDependencies }): UnEncapsulatedPlugin<TEnhanced>
export function createPlugin<
  TPlugin extends FastifyPluginCallback,
  TDependencies extends FastifyDependencies,
  TEnhanced extends ApplyDependencies<TPlugin, TDependencies> = ApplyDependencies<TPlugin, TDependencies>
> (plugin: TEnhanced, options?: { dependencies?: TDependencies }): UnEncapsulatedPlugin<TEnhanced>
export function createPlugin<
  TPlugin extends FastifyPluginAsync,
  TDependencies extends FastifyDependencies,
  TEnhanced extends ApplyDependencies<TPlugin, TDependencies> = ApplyDependencies<TPlugin, TDependencies>
> (plugin: TEnhanced, options?: { dependencies?: TDependencies }): UnEncapsulatedPlugin<TEnhanced>
export function createPlugin<
  TPlugin extends FastifyPlugin,
  TDependencies extends FastifyDependencies,
  TEnhanced extends ApplyDependencies<TPlugin, TDependencies> = ApplyDependencies<TPlugin, TDependencies>
> (plugin: TEnhanced, options?: { dependencies?: TDependencies }): UnEncapsulatedPlugin<TEnhanced> {
  return plugin as UnEncapsulatedPlugin<TEnhanced>
}

I could use a hand on the implementation side of things.

I am also quite torn on whether this should be provided alongside fastify core, because i needed it most of the time during testing. Do you have an opinion on that?

Thanks!

@nemanja-tosic
Copy link
Copy Markdown
Author

nemanja-tosic commented Nov 13, 2024

I didn't see that fastify-plugin was a dev dependency of fastify. I took advantage of that and create a new createPlugin function in fastify-plugin. We can either use a new function or change the old one. I'll weigh the options. I believe unless anyone can come up with further things we can tests i've reached a point where i'd say it could be reviewed.

What remains then is to figure out the final implementation details in fastify-plugin.

@jean-michelet
Copy link
Copy Markdown
Member

I'll take a look this week, thanks again for your work!

Plz @climba03003, can you take a look took too, it looks like an interesting PR.

Copy link
Copy Markdown
Contributor

@rozzilla rozzilla left a comment

Choose a reason for hiding this comment

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

This update contains important breaking changes. For instance, the number (and type) of the generics for the following types are updated:

  1. FastifyPluginCallback
  2. FastifyPluginAsync
  3. FastifyPlugin
  4. FastifyReply (Decorators generic is added at the current position of ReplyType generic)
  5. FastifyRequest (as above)

If we want to proceed that way, I think we should mark it as a breaking change (so it should be included in v 6.x). Anyway, if possible, it'd be better to update this code so that we can keep TypeScript compatibility in the current version, so that we can ship it as it is.

Comment on lines 10 to +14
export type FastifyPluginCallback<
Options extends FastifyPluginOptions = Record<never, never>,
Server extends RawServerBase = RawServerDefault,
TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault,
Logger extends FastifyBaseLogger = FastifyBaseLogger
> = (
instance: FastifyInstance<Server, RawRequestDefaultExpression<Server>, RawReplyDefaultExpression<Server>, Logger, TypeProvider>,
opts: Options,
done: (err?: Error) => void
) => void
Options extends FastifyPluginOptions = FastifyPluginOptions,
TIn extends AnyFastifyInstance = AnyFastifyInstance,
TOut extends void | AnyFastifyInstance = void | AnyFastifyInstance
> = (instance: TIn, opts: Options, done: (err?: Error) => void) => TOut
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hi folks! 👋🏼😊 I agree with @jean-michelet, and we should be super careful with such an update, since it looks like a breaking change to me. Considering existing code like:

  import type {
    FastifyBaseLogger,
    FastifyInstance,
    RawReplyDefaultExpression,
    RawRequestDefaultExpression,
    RawServerDefault,
  } from 'fastify';

  import type { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts';

  const dateSchema = {
    $id: 'date',
    type: 'string',
    format: 'date',
  } as const;

  const jsonReferences = [
    ...dateSchema,
    // all of my JSON Open API schema references ...
  ]

  export type JsonTypeProvider = JsonSchemaToTsProvider<{ references: typeof [] }>;

  export type FastifyJsonSchemaInstance = FastifyInstance<
    RawServerDefault,
    RawRequestDefaultExpression<RawServerDefault>,
    RawReplyDefaultExpression<RawServerDefault>,
    FastifyBaseLogger,
    JsonTypeProvider
  >;

This will break the TS build 🤷🏼

@nemanja-tosic
Copy link
Copy Markdown
Author

nemanja-tosic commented Nov 29, 2024

This update contains important breaking changes. For instance, the number (and type) of the generics for the following types are updated:

  1. FastifyPluginCallback
  2. FastifyPluginAsync
  3. FastifyPlugin
  4. FastifyReply (Decorators generic is added at the current position of ReplyType generic)
  5. FastifyRequest (as above)

If we want to proceed that way, I think we should mark it as a breaking change (so it should be included in v 6.x). Anyway, if possible, it'd be better to update this code so that we can keep TypeScript compatibility in the current version, so that we can ship it as it is.

There is a comment for FastifyRequest for RequestType, i assumed the same applies for FastifyReply. Granted the same comment isn't present, but it looks to be the same case.

  Decorators extends FastifyDecorators['request'] = FastifyDecorators['request'],
  RequestType extends FastifyRequestType = ResolveFastifyRequestType<TypeProvider, SchemaCompiler, RouteGeneric>
// ^ Temporary Note: RequestType has been re-ordered to be the last argument in
//   generic list. This generic argument is now considered optional as it can be
//   automatically inferred from the SchemaCompiler, RouteGeneric and TypeProvider
//   arguments. Implementations that already pass this argument can either omit
//   the RequestType (preferred) or swap Logger and RequestType arguments when
//   creating custom types of FastifyRequest. Related issue #4123

EDIT: that said, decorators CAN be last, since i don't expect the to be explicitly passed. I only did it this way to preserve what i thought was wanted.

@mcollina
Copy link
Copy Markdown
Member

I think we can assume we'll ship this in v6 unless it could be made less breaking. This will give us time to go update all the plugins too.

@nemanja-tosic could you add a little bit of a migration guide? Thanks

@nemanja-tosic
Copy link
Copy Markdown
Author

nemanja-tosic commented Nov 29, 2024

I'm not seeing the comments i have on a reviewed file so i will summarize here. The concern for PluginCallback is valid due to the type change, since the type is used to declare plugins and forcing a migration immediately might be difficult. I will take a look if it is possible to support the previous version alongside the new one using https://github.com/fastify/fastify-type-provider-json-schema-to-ts as an example. Acceptance criteria would be that

  • fastify-static is changed so that module augmentation is removed but decorators are added (done)
  • fastify-type-provider-json-schema-to-ts can remain unchanged (in progress)

Based on that i will know what is needed for migration.

Based on the reviews, FastifyPluginCallback, FastifyPluginCallbackAsync and FastifyPlugin are the main concerns for the work so far, so i will attempt to make those backwards compatible. Thank you for the review @rozzilla

@rozzilla
Copy link
Copy Markdown
Contributor

rozzilla commented Nov 29, 2024

Acceptance criteria would be that

  • fastify-static is changed so that module augmentation is removed but decorators are added (done)
  • fastify-type-provider-json-schema-to-ts can remain unchanged (in progress)

Yup, exactly! By moving Decorators as the last param (and with a default value) the change will be backward compatible.

I would also add another criteria we should consider:

  • existing tsd tests for types, should remain as of today

If we need to update an existing type test, this means the change is not backwards compatible

@nemanja-tosic
Copy link
Copy Markdown
Author

tsd cannot remain as they are today due to the differences between expectType and expectAssignable. The changes update the instance type in order to enforce type safety, so tests will likely have to follow suite.

@mcollina
Copy link
Copy Markdown
Member

mcollina commented Dec 7, 2024

@jean-michelet could you take another look at this PR?

@jean-michelet
Copy link
Copy Markdown
Member

jean-michelet commented Dec 7, 2024

I don't feel confident/competent enough to give more feedback than I've already given.

In short :

  1. I am a very pessimistic person.
    Even if we don't really understand how we could break the ecosystem for now, I think these changes should not be accepted without first consulting package maintainers that rely heavily on Fastify generics (or at least ping them for a review, so they can choose to ignore).

  2. I think the changes lead to a major improvement in developer experience, so some breaking changes are acceptable to me. Once we have more positive feedback, you can ping me and I help you to update the whole core plugin ecosystem. We can also create a new branch on the demo as a showcase for migration.

@Eomm
Copy link
Copy Markdown
Member

Eomm commented Jan 17, 2026

Closing as too old

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

Labels

typescript TypeScript related

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants