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

Type annotation for all exports in the module #38511

Open
5 tasks done
mohsen1 opened this issue May 12, 2020 · 42 comments
Open
5 tasks done

Type annotation for all exports in the module #38511

mohsen1 opened this issue May 12, 2020 · 42 comments
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@mohsen1
Copy link
Contributor

mohsen1 commented May 12, 2020

Search Terms

Export, typed exports, modules, Next.js, Redwood.js

Suggestion

Allow a new syntax From issue #420 that will allow typing all of exports in a module.

export implements {
  default: string
  foo: (bar: boolean) => number
}

export default "Hello world!";
export function foo(bar) { return 1 }

Use Cases and Examples

Increasingly frameworks are using named module exports as a way of organizing their code. For instance Next.js is using named exports to specify things like exported component and functions for fetching data:

import { PageModule } from 'next'

type Post = { title: string };
export implements PageModule<{ posts: Posts[] }>;

function Blog({ posts }) {
  return (
    <ul>
      {posts.map(post => (
        <li>{post.title}</li>
      ))}
    </ul>
  )
}

export async function getStaticProps() {
  return { props: { posts: [] } }
}

export default Blog

Frameworks relying on named export

Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

Related issues

@balazsorban44
Copy link

You can add Remix to this category

https://blog.remix.run/p/remix-preview

It will export a load function similar to Next.js's getServerSideProps

@kentcdodds
Copy link

I didn't notice this issue when I opened: #46942

There are a few more details for use cases and an alternative syntax, but I don't really care too much about the specific syntax. I just want the feature. :) You've got a 👍 from me!

@Rich-Harris
Copy link

Adding SvelteKit to the list of frameworks that follows this pattern — this would be hugely beneficial to us as well.

An even better outcome for users would be if modules could be typed implicitly — for example if certain files (either with a particular naming convention or because the framework generates a config that lists them) were known to implement a particular interface, the export implements (or whatever) would be unnecessary (unless you needed to specify type variables). For example a typical SvelteKit endpoint looks like this:

// src/routes/echo.ts
import { RequestHandler } from '@sveltejs/kit';

export const get: RequestHandler = (request) => {
  return {
    status: 200,
    headers: {},
    body: JSON.stringify(request)
  };
}

Converting that to an explicitly typed module...

// src/routes/echo.ts
-import { RequestHandler } from '@sveltejs/kit';
+import { Endpoint } from '@sveltejs/kit';
+
+export implements Endpoint;

-export const get: RequestHandler = (request) => {
+export function get(request) {
  return {
    status: 200,
    headers: {},
    body: JSON.stringify(request)
  };
}

...would only really start paying dividends once you had multiple exports, whereas an implicitly-typed module would provide significant benefits even to people who see the red squigglies but otherwise have no idea what TypeScript is:

// src/routes/echo.ts
-import { RequestHandler } from '@sveltejs/kit';

-export const get: RequestHandler = (request) => {
+export function get(request) {
  return {
    status: 200,
    headers: {},
    body: JSON.stringify(request)
  };
}

@kentcdodds
Copy link

Hey @Rich-Harris 👋

I'd also love implicit types based on a convention and the tsconfig-based configuration of that convention (😆). That would be a big benefit for Remix users as well.

I'm concerned that this would stall the feature. If there's some way to have both and start with the explicit that's the future I would prefer personally. I think explicit has a place in the TS ecosystem as well, so both make sense to me. So if we could get the explicit finished and start benefitting from that then work on the implicit that would be super :)

@Rich-Harris
Copy link

Indeed. If feature B depends on feature A, it's better to implement feature A first rather than waiting to implement both.

Nevertheless, it's very useful in my experience to understand the rough shape of feature B (and any other dependent features you have in mind) before designing feature A, lest you end up with a design that for whatever reason makes those follow-ups hard or impossible. For that reason it's often worthwhile to include those considerations in the initial discussion.

@GregBrimble
Copy link

GregBrimble commented Dec 1, 2021

+1 from Cloudflare Pages as well!

We declare this PagesFunction type in our @cloudflare/workers-types package, and encourage people to include it in their types in tsconfig.json:

{
  "compilerOptions": {
    "types": ["@cloudflare/workers-types"]
  }
}

With support for type-checking of modules, we could declare a PagesFunctions or similar:

export implements PagesFunctions

export const onRequestPost = async ({ request }) => {
  const { name } = await request.json()
  return new Response(`Hello, ${name}!`)
}

export const onRequestGet = () => {
  return new Response("Hello, user!")
}

But this API would do exactly what we'd need! Thanks everyone for suggesting it :)

@geelen
Copy link

geelen commented Dec 1, 2021

@Rich-Harris love your idea of implicit naming based on file pattern, imo in tsconfig something like:

{
  "compilerOptions": {
    "exportTypings: {
      "*.svelte.ts": "@sveltejs/kit::Endpoint"
    }
  }
}

Maybe with something less c++-y than :: but you get the idea.

I think <module-name>.<exports-types>.ts could become a fairly common convention. But equally, for all-one-type projects you could just set *.ts to be any type you like, or for Pages we could set a subdir like:

"functions/**/*.ts":"@cloudflare/workers-types::PagesFunctions"

I'm liking this idea more and more...

@orta
Copy link
Contributor

orta commented Dec 8, 2021

Notes from the last time we talked about this: #38713 and some of the possible consensus #420 (comment)

@kentcdodds
Copy link

Thanks @orta. Looks like there's community interest and team appetite for the idea. So where do we go from here? How do we push things forward?

@shilman
Copy link

shilman commented Dec 16, 2021

Mentioned in other threads on the topic, Storybook's Component Story Format also heavily relies on this pattern, where the default export includes metadata about a component, and each named export is a component example, or story:

import { Meta, Story } from '@storybook/react';
import { Button } from './Button';

const meta: Meta = {
  title: 'Demo/Button',
  component: Button;
};
export default meta;

export const Primary: Story = () => <Button primary>Primary</Button>;

export const Disabled: Story = () => <Button disabled>Disabled</Button>;

In our case, users also want more type safety and better autocompletion than the general types provide, so they will parameterize them by the props of the component that is being documented. So at the top of each file they might make specific versions at the top of each file:

type Meta = ComponentMeta<typeof Button>;
type Story = ComponentStory<typeof Button>;

const meta: Meta = { ... };
export default meta;

export const Primary: Story = // ...;

Ideally a solution would be powerful enough to express this kind of specificity.

So this would fit the bill in each file:

export implements CSF<typeof Button>;

But this proposal in its current form would not:

"src/**/*.stories.tsx":"@storybook/react::CSF"

A combination of the former (for users who want more control) and the latter (for convenience) would be amazing. 💯

@hollandThomas
Copy link

hollandThomas commented Jan 7, 2022

Nevertheless, it's very useful in my experience to understand the rough shape of feature B (and any other dependent features you have in mind) before designing feature A, lest you end up with a design that for whatever reason makes those follow-ups hard or impossible. For that reason it's often worthwhile to include those considerations in the initial discussion.

In this spirit, I just wondered about the following as a possible future extension of this proposal:
A module that implements named exports may infer the types of any imports that originate from the same module that provides the named export interface.

I am not sure about the syntax and how to implement this but maybe someone can come up with something smart.

Taking Remix as an example, this would enable e.g.

// Internal Remix code
interface EntryRouteModule<LoaderData = Response | AppData> {
  CatchBoundary?: CatchBoundaryComponent;
  ErrorBoundary?: ErrorBoundaryComponent;
  default?: RouteComponent;
  handle?: RouteHandle;
  links?: LinksFunction;
  meta?: MetaFunction | HtmlMetaDescriptor;
  action?: ActionFunction;
  headers?: HeadersFunction | { [name: string]: string };
  loader?: LoaderFunction<LoaderData>;
}

Then, in a route

// routes/posts.tsx
import type { EntryRouteModule } from 'Remix'
import { useLoaderData } from 'Remix';

type Post = { title: string };
export implements EntryRouteModule<{ posts: Post[] }>;

// this is typed correctly because of the line above
export const loader = async () => {
  return {
    posts: await postsQuery()
  }

export default function Blog() {
  // Have TypeScript figure this out automatically somehow:
  // useLoaderData now knows that `LoaderData === Post[]`. There is no need to `useLoaderData<Post[]>`
  const { posts } = useLoaderData()

  return (
    <ul>
      {posts.map(post => (
        <li>{post.title}</li>
      ))}
    </ul>
  )
}

This is a slightly different use case than the one outlined in the initial Next.js example, where the inference of posts === Post[] does not rely on any additional imports.

import { PageModule } from 'next'

type Post = { title: string };
export implements PageModule<{ posts: Post[] }>;

// Here, `posts` is being inferred correctly as per proposal
function Blog({ posts }) {
  return (
    <ul>
      {posts.map(post => (
        <li>{post.title}</li>
      ))}
    </ul>
  )
}

export async function getStaticProps() {
  return {posts: []}
}

export default Blog

@mohsen1
Copy link
Contributor Author

mohsen1 commented Jan 19, 2022

@DanielRosenwasser just let me know that it's possible to import self to simulate what we're asking for. Just in case you're curious this is how it would look like in Next.js for instance:

// @file pages/posts.tsx

import React from 'react';

// This can be more complex to cover getServerSideProps etc
type InferPropTypes<T> = T extends { getStaticProps: () => Promise<{ props: infer U }> }
  ? U
  : never;

import * as self from './posts';
const Posts: React.FC<InferPropTypes<typeof self>> = ({ posts }) => {
  return (
    <ul>
      {posts.map((post) => (
        <li>{post.title}</li>
      ))}
    </ul>
  );
};

export async function getStaticProps() {
  const posts: Array<{ title: string }> = [];
  return { props: { posts } };
}

export default Posts;

This is less automatic than typing all exports using something like export implements but it's possible to do today.

@ivanhofer
Copy link

I would also love to see such a feature in TypeScript.
Probably every project that has file conventions would benefit from such a feature. My project typesafe-i18n could also benefit from it.

I don't think that there is a new syntax needed. What if TypeScript could use a .d.ts file to check what variables and exports a file should have?

Currently e.g. a file.d.ts would tell every other file that imports file.js what values it exports and what shape those variables and functions have. But in that file.js no type-check happens. The file.js could also export something totally different and TypeScript would not throw an error:

file.d.ts

declare export someVariable: number

file.js

export someVariable = 'Hello'

So if TypeScript would use the information from the d.ts file to check if the .js file was implemented correctly, we could have a solution to the typed export problem. Of course this should also work with .ts files.

With a solution like we can see in SvelteKit where types are generated and then linked with the real source files via the rootDirs compiler option, those .d.ts files could be generated without adding declaration files inside the actual src folder.

Having those checks enabled would probably break a few existing code bases if they manually create .d.ts files. Or actually they would get notified that they defined something wrong ;)
But this is a topic for a later discussion. I just want to propose an alternative to this thread.

@Rich-Harris
Copy link

That's a stroke of genius @ivanhofer — it solves the problem very elegantly. I've often wondered why .d.ts files don't already work that way.

My conclusion was that TypeScript probably assumes that foo.d.ts is generated from foo.ts and as such represents a snapshot of its declarations the last time tsc was run, but even though emitting .d.ts files as siblings to their inputs is the default behaviour if "declaration" is true and "declarationDir" is unset, I've never seen a case where it's actually desirable.

I wonder if a new compiler option like "selfDeclaration": true could solve this problem?

@ivanhofer
Copy link

Yes usually the .d.ts should be genereated from the actual source file. But there exist a lot of libraries that are written in plain JavaScript and types are added by manually creating the .d.ts file or there are also all the @types packages were the types come from a 3rd party.

Using the declaration files to type the corrresponding source file makes only sense for your own source files (everything that matches "include" from your tsconfig.json).
The more I think about this topic, the less I understand the skipLibCheck option. Why should I want TypeScript to check files from my node_modules folder I don't use in my code? (maybe I also don't fully unserstand what it really does)

A compiler option where someone could opt-in into the .d.ts typechecking behaviour would be a good solution. I could also immagine this beeing one of the "strict" options since it potentially warns you about big mistakes in your code base.

Such a feature would also make it possible to have "scoped global types" (not sure how to call them). A concrete example for SvelteKit would be the GET type you can use to type endpoints. Currently you have to manually import the type and also be sure you import the generated type and not the generic type. With the .d.ts file the GET type could be "just there". No need to import it. Like the interface HTMLElement can be used everywhere without importing it directly. But in this case the type would be scoped to a single file.

@camjackson
Copy link

@ivanhofer With your suggested solution, would that I mean I need to create a .d.ts file for every module that I want to type? So with Remix as an example, for every single one of my route .ts files I would need to create a corresponding .d.ts file, all of which would have identical contents (seeing as all route modules follow the same pattern)?

@ivanhofer
Copy link

@camjackson I haven't used Remix yet, so I don't fully know how route files work there. I just looked at some examples in the comments above.
If you want all your route files beeing typed without a user having to import the correct type, then with the solution I have suggested probably yes.
But those files don't have to contain a dozen of lines. I can imagine having a file where you define how a route should look like and then just writing export * from '@framework/common-route' for all other files.
Having those files located in your normal src folder is probably not a good user experience. I really like the solution of auto-generating and hiding those files via the rootDirs option like SvelteKit does.

@camjackson
Copy link

camjackson commented Aug 6, 2022

So instead of exporting each function individually a framework could choose to require to call a provided function that allows to check for type errors.

I suggested this on the Remix discord and it was explained to me that it won't work. In Remix, route modules include both frontend and backend code, and the framework needs to be able to tree shake / code split the code apart. So its conventions must be export-based, they can't be fields of a single object. That's why this proposal is needed.

@ivanhofer
Copy link

Surely there's a halfway point here? I think @ivanhofer's thought of using .d.ts as an assertion format is absolutely genius, but I agree having to have one-per-input file is cumbersome. So why not apply it a different way?

I agree, having a config option to choose the behaviour where .d.ts files get applied would be an extension to the solution that makes sense.
The reason why I proposed this soultion with exactly that state is: for me it looks like it is already implemented in the code base. Just not enabled. I don't want to offend any TypeScript maintainer but for me (without ever looking at the code) it just feels like removing an if statement somewhere or adding two lines of code to enable that feature.

I suggested this on the Remix discord and it was explained to me that it won't work.

Well, from a technical perspective Remix could also transform the file and split it up into distinct files during the build process. Maybe a bit overkill, but it could already work with the current TypeScript version.
"Where there is a will, there’s a way" :)

@tigerabrodi
Copy link

This would be HUGE 🔥

@nickserv
Copy link
Contributor

This could also be helpful for globals that are only exposed in certain files, like Node environment variables and test frameworks.

@mohsen1
Copy link
Contributor Author

mohsen1 commented Nov 11, 2022

3 questions:

  1. should we allow multiple export implements and treat it like an interface extending multiple other interfaces?
  2. if exports is acting like a type identifier then should typeof exports be a valid syntax?
  3. Are you accepting pull requests for this?

@kentcdodds
Copy link

As an interested user, here are my opinions:

  1. Maybe? Personally I'd just do export implements Type1 & Type2
  2. Hmmm... I don't know what use cases that would be useful for 🤔
  3. I hope they are!

@slorber
Copy link

slorber commented Nov 15, 2022

Next.js 13 solution to mitigate for the lack of this feature (+ more):

CleanShot 2022-11-15 at 10 56 57@2x

@mohsen1
Copy link
Contributor Author

mohsen1 commented Nov 15, 2022

@kentcdodds typeof exports can be useful to export the type of all exports in a file for some use:

export const foo = 1
export const obj: { bar?: string } = {}

export type Exports = typeof export; // { foo: number; obj: { bar?: string } }

I don't have an immediate use case for it but I'm sure this can be useful

@antl3x
Copy link

antl3x commented Nov 24, 2022

Put this at your module file and __MODULE_TYPE_CHECK__ will do the job.

File: myModule.ts

/* myModule.ts */

/* ------------------------------- Type Check ------------------------------- */

type _myModule = typeof import('./myModule');
const __MODULE_TYPE_CHECK__: MyModuleInterface = {} as _myModule;

Ugly? Yes, but it works.

If your module does not export const ... and does not respect your interface, you will receive a type error.

There is no cyclical-dependency since the import is "garbage collected" and is not transpiled.

@kentcdodds
Copy link

kentcdodds commented Nov 24, 2022

Handy trick to help error if your exported types are wrong. However, it doesn't help with auto-complete nor will it report errors in a convenient location.

@antl3x
Copy link

antl3x commented Nov 24, 2022

Handy trick to help error if you're exported types are wrong. However, it doesn't help with auto-complete not will it report errors in a convenient location.

Not ideal, but for auto completion:

const MODULE: MyModuleInterface = {
    someProperty: ...,
    someMethod(param) { }
}

export const someMethod = MODULE.someMethod

or

const MODULE: MyModuleInterface = {
    someProperty: ...,
    someMethod(param) { }
}

export const { someProperty, someMethod } = MODULE;

@ggoodman
Copy link

I'd like to add another use-case for this feature. We use monaco-editor and its typescript language services to provide an in-browser, IDE-like editing experience for Auth0 Actions. Actions are CJS modules that we execute in a 'serverless' environment on behalf of our customers at specific lifecycle events in our auth pipelines. It is conceivable that we could move to EJS modules if that would be the gateway to having access to this feature if it came to TypeScript.

Currently, we rely on seeding Actions with skeleton templates that use jsdoc annotations to power the intellisense. This works but means that the majority of the template is consumed by non-functional boilerplate. Supporting typed modules such as in this proposal would give us (and I suspect all other serverless-like platforms / tools) an improved way to deliver a more robust and elegant DX for our customers / users.

@jamiebuilds-signal
Copy link

jamiebuilds-signal commented Dec 20, 2022

One syntax option:

export {} satisfies Type

This would be a shorthand for typing exports of the entire module:

export {} satisfies RemixRoute<{ bookId: string }>

export function action({ requesr }) // Did you mean "request"?
export function loader({ params }) {
  params.bookID // Did you mean "bookId"?
}

It's important to keep in mind that the {} in export {} is not an object literal, and can contain things like export { Component as default }

One benefit of this would be that you are also typing the syntax that marks a module as a module, and TypeScript could emit it export {}

@ggoodman
Copy link

One thing I don't like so much about the approaches that involve new syntax is that it requires author of the subject module to opt in. From what I understand of the use-cases that motivate this issue, the types for module exports are not optional. Instead, these types are being enforced by an external factor or framework. I think it would be interesting if the enforcement could thus come from outside too.

From that angle, I wonder if it might be interesting to think about a design where these module-level type signatures are 'attached' to files through the tsconfig.json and equivalent language service constructs.

A straw-man idea follows. Here, we can define a set of mappings from files globs to a module specifier and exported symbol that defines the expected shape of the matching files' exports.

{
  "exportTypes": [{
    "include": ["**/pages/*.ts"],
    "typePath": "@framework/types",
    "typeName": "PageExports",
  }],
}

In other words, any file matching the glob **/pages/*.ts would have the signatures of its exported symbols checked against the PageExports type exported from the @framework/types module. The type checker and language server would be able to enforce the types at compile-time and suggest them at authoring time, respectively.

The side-benefit is that affected files no longer need to include superfluous syntax to benefit from this feature. Many frameworks area already providing default tsconfig.json settings from which applications extends so I could imagine this slotting nicely into that sort of setup.

@kentcdodds
Copy link

I like that. Though in Remix at least, you can very dynamically define what files count as routes so I would like an additional capability to define a type for the exports.

I'm a fan of what @jamiebuilds-signal suggests with the satisfies keyword.

@camjackson
Copy link

camjackson commented Dec 21, 2022

From that angle, I wonder if it might be interesting to think about a design where these module-level type signatures are 'attached' to files through the tsconfig.json and equivalent language service constructs.

I see the benefit of that, especially the idea of it being enforced from the outside, because it's a framework-level expectation. My concern is that it makes the code much less discoverable. To me it's very important that a new developer can look at a piece of code, without any context, and be able to figure out what it does by following the breadcrumbs.

I prefer an API that explicitly establishes a link that says "this code that you're looking at implements this interface", and then you can click through to see what that interface is. As opposed to it being configured somewhere else, with no way of like, naturally discovering it from the code in question. It becomes something that you just have to know.

@ggoodman
Copy link

I prefer an API that explicitly establishes a link that says "this code that you're looking at implements this interface", and then you can click through to see what that interface is.

That's a great point. Perhaps the relevant comparison here is what you have defined in your tsconfig.json for the compilerOptions.lib option. This is something defined on the 'outside' that affects which ambient types will show up. For use-cases where you want to be explicit, you can opt into a triple-slash directive to make the dependency explicit.

@btoo
Copy link

btoo commented Dec 21, 2022

allowing ambient module declarations to specify relative module names as an alternative to using tsconfig could be a more intuitive and/or powerful way to achieve this by way of using typescript (especially satisfies) rather than json

import { type RemixRoute } from 'remix';

declare module './MyRoute' {
  export {} satisfies RemixRoute<{ bookId: string }>
}

and this could work with wildcards as well

import { type PageExports } from 'next';

declare module './**/pages/*.ts' {
  export {} satisfies PageExports
}

a more drastic change, module declarations themselves could satisfy types

declare module './MyRoute' satisfies RemixRoute<{ bookId: string }>

declare module './**/pages/*.ts' satisfies RemixRoute<{ bookId: string }>

@ggoodman
Copy link

Oh nice, @btoo that's very clever!

@mohsen1
Copy link
Contributor Author

mohsen1 commented Dec 23, 2022

I like export {} satisfies Type because satisfies works better than implements in this case. The only thing is the emitted export {} in runtime space that might not be desired.

@EloB
Copy link

EloB commented Jan 23, 2023

@btoo Is that doable at the moment or is that a a feature request?

Unarray added a commit to Virtual-Royaume/Royaume-Discord-Bot that referenced this issue Feb 25, 2023
Objectifs de la PR :
- Retirer toute les classes du projet pour plus être orienté vers de la programmation fonctionnelle
- Créer un système d'auto-loading beaucoup plus propre avec des fichiers séparer les uns des autres
- Utilisation d'arrows functions a la place de regulars functions
- Nouvelle convention de nommage des fichiers *(kebab-case, fonctionnalité du fichier défini en `file.functionnalité.ts`, export barrel pour les choses pas auto load)*

Pour l'instant les exports sont un peu défini de manière magique, mais dans le futur cette PR viendra fix ça :  microsoft/TypeScript#38511
@mariusGundersen
Copy link

I would really like this too, and would be happy with export {} satisfies Type, although some magic with tsconfig and wildcard paths would be very useful.

A workaround while we wait for this feature is to import the module into itself and then check that what is imported satisfies some type. For example:

// myModule.ts
export default function () {
  return "test";
}

import * as __SELF__ from './myModule';
__SELF__ satisfies { default(): string };

This is quite cumbersome though, since you need to specify the name of the file in the file, and you get the error in the satisfies line, not on the export line.

balazsorban44 added a commit to vercel/next.js that referenced this issue Feb 5, 2024
### What?

Expose the `MiddlewareConfig` interface.

### Why?

You can now `import type { MiddlewareConfig } from "next/server"` to
type the `config` object in your `middleware.ts` file.

Now you an type the entire file for example like so:
```ts
// middleware.ts
import type { NextMiddleware, MiddlewareConfig } from "next/server"

export const middleware: NextMiddleware = async (req) => {
  //...
}

export const config: MiddlewareConfig = {
  //...
}
```

### How?

Re-exported the interface from its current location via
`server/web/types`, to colocate it with `NextMidldeware`.

I wonder if we could somehow type this file automatically, but it might
be dependent on microsoft/TypeScript#38511

Closes NEXT-2308

[Slack
thread](https://vercel.slack.com/archives/C03S9JCH2Q5/p1706287433026409?thread_ts=1706058855.423019&cid=C03S9JCH2Q5),
[Slack
thread](https://vercel.slack.com/archives/C03KAR5DCKC/p1706659724141899)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests