microsoft/TypeScript

Allow a module to implement an interface

ivogabe opened this issue Β· 59 comments

It would be useful when a module can implement an interface using the implements keyword. Syntax: module MyModule implements MyInterface { ... }.

Example:

interface Showable {
    show(): void;
}
function addShowable(showable: Showable) {

}

// This works:
module Login {
    export function show() {
        document.getElementById('login').style.display = 'block';
    }
}
addShowable(Login);

// This doesn't work (yet?)
module Menu implements Showable {
    export function show() {
        document.getElementById('menu').style.display = 'block';
    }
}
addShowable(Menu);

How would this work with external modules? It's likely once people can use it for internal, they'll also want to use it with external.

That's a good question. I don't know which syntax would be the best, but here are a few suggestions:

implements Showable; // I would prefer this one.
module implements Showable;
export implements Showable;

It should only be allowed on external modules that don't use an export assignment, since if you use an export assignment, the thing that you export can already have an implements on another place.

Approved. We prefer the syntax

export implements Showable;

and agreed that this is unneeded for files export = assignments.

Some more questions:

  • Since we are allowing modules to have types at declaration sites, should not we allow them to have types at use sites as well. e.g.:
declare module "Module" implements Interface { }

import i : Interface = require("Module");
  • What do you do with merged declarations, should you enforce the interface on the aggregate of all declarations? and what happens if they do not match in visibility?
    e.g.:
module Foo {
    export interface IBar {
        (a:string): void;
    }

    export module Bar implements IBar {  // should this be an error?
        export interface Interface {}
    }    

    function Bar(a: string) : void { }  // not exported
}

var bar: Foo.IBar = Foo.Bar;

It should be allowed on ambient external modules. For these modules two syntaxes should be allowed in my opinion:

declare module "first" implements Foo { }
declare module "second"  {
  interface Bar { }
  export implements Bar; // this syntax is necessary, with the first syntax you can't reference Bar.  
}

Or should Bar be in the scope in an implements clause before the opening {?

Adding type info to an import statement isn't really useful in my opinion, since you can add the type info to the module itself.

And for merged declarations, I'd say that the module block that contains the implements clause should implement the interface. That also prevents issues with visibility.

How would this be related to #2159? A namespace implements an interface?

@jbondc If we had this, it would also apply to namespaces. You should think of internal modules and namespaces as isomorphic.

Are you sure you want to go down an implementational path where "namespaces" can implement interfaces?

Oh wow, this has been approved for quite a while. @RyanCavanaugh, @DanielRosenwasser, @mhegazy unless you have any second thoughts or tweaks, I'll probably implement this soonish.

I withdraw my previous skepticism, I actually exited for the new structural possibilities it would bring.

In line with that, please consider enforcing the interface of the aggregate of the interface instead of only the block that declares the implementation - The nature of namespaces/modules is to be spread out and to contain a lot of non-trivial components. I'd like to be able to use this, but I certainly don't want to define my whole namespace/module in the same file. Why not just use a class in that case?

@Elephant-Vessel I'm not sure if we are talking about Modules, or Namespaces, or Packages, or Features, or...

@aluanhaddad What do you mean?

I mean that at the time that this discussion started module didn't mean what it means today. We now use the term namespace to refer to what is described in the OP as a module, while module has taken on a more precise and incompatible meaning. So when you talk about multiple files taking part in this implementation are you referring to namespaces or modules?

I'm referring to namespaces. I guess I just wanted to conform to the history of this thread, sorry for not breaking loose :) Or when I think of it, maybe I had the generic term 'module' in my head, describing a higher-level unit consisting of set of sub-components, assembled to provide certain high-level functionality in a system. But I'm fine with just going with 'namespaces'.

So I want to be able to describe and put constraints and expectations on [generic modules] that can contain other [generic modules] or classes, taking advantage of the structural concept namespaces in typescript.

My hope is that we'll be able to better express higher-level structural expectations in a system. Classes do not scale well, they are fine as atomic components in a system, but I don't think that higher-level organizational structure in a system would good to express with classes as they are designed to be instantiated and inherited and stuff like that. It's just too bloaty.

I'd appreciate a simple and clean way to describe higher order structure of the system, no fuss. Preferably with the only fuss being optional directional visibility constraints. Like making it impossible to reference MySystem.ClientApplication from MySystem.Infrastructure but fine the other way around. Then we'd start to go somewhere exciting.

@Elephant-Vessel thanks for clarifying. I agree this would be extremely valuable and that class types are not the right approach here. I think you hit the nail on the head when talking about instantiation because namespaces represent things that are conceptually singletons at the library level. Although this can't be enforced, it would be useful conceptually to have something that does not imply multiple instantiations.

I agree with @Elephant-Vessel. While it is easy to mistaken TypeScript for another Java, where all constraints are expressed with a single class structure, TS has a much broader "Shape" concept which is very powerful and eliminates semantic contortonism. Unfortunately, the inability to put constraints on module tend to force developers to relegate back to a class pattern for things that would be much better expressed as module.

For example, for unit testing, it would be very helpful to be able to express some "shape" (i.e. constraints) on modules so that we can provide alternative implementation for a particular running context. Now, it seems the only way to do that in a structure/checked way is to go back to class based DI (as la Spring) and make everything a class (and therefore instantiable).

Anyway, I am paraphrasing @Elephant-Vessel, but if I have a single wish for TS, it would be this one.

Any word on this bird? I have this issue as well

soooo, uhh, wouldn't it be a simple case of:

export {} as IFooBar;

what's wrong with that syntax? I guess the syntax has already been approved, perhaps as

export implements IFooBar

anyway looking forward to it

Has this matriculated / landed yet? this is going to be a cool feature

How can we progress this? Its incredibly powerful. Happy to help out!

any worb on this birb? One question I have for the moment, is how can I declare an interface for the default export. For example:

export default {}

I suppose I can just do:

const x: MyInterface = {}
export default x;

that would work for most TS files, the problem with it tho, is that if you are coding for JS first and planning to transition to TS later, then this doesn't work so well.

Another thing I was thinking of, what about namespaces that implement? Something like:

export namespace Foo implements Bar {

}

I guess Bar would be an abstract namespace lol idk

Seen this question rise up so many times, and I think we are all just looking for one thing:
Support static members in an interface.
If that would happen, you could just use a class with static members and an interface, which is almost the same thing as you are trying to do here, right?

Either way, add static support to interfaces OR add interface support for modules is highly needed.

@shiapetel nah not like that.

we can do this:

export default <T>{
  foo: Foo,
  bar: Bar
}

but that's not what we are looking for. we are specifically looking for:

export const foo : Foo = {};
export const bar : Bar = {};

but there's currently no mechanism to enforce the module to export foo and bar. And in fact there's no mechanism to enforce that the module export the right default value either.

If interfaces supported static members, you could use a class with static foo/bar that inherited from:
Interface ILoveFooBar{
static foo:FooType;
static bar:BarType;
}

Right?
That’s what I meant, I think it would help in your situation- I know it would definitely help in mine.

@shaipetel static members of interfaces definitely might be useful, but perhaps not for this use case.

Is this issue just waiting for someone to have a go at implementing?

One use case would be for frameworks and tools that scan a directory for modules on application startup, expecting those modules all to export a certain shape.

For example, Next.js scans ./pages/**/*.{ts,tsx} for your page modules, generating routes based on your filenames. It's up to you to ensure each module exports the right things (a NextPage as the default export, and an optional PageConfig export named config):

import { NextPage, PageConfig } from 'next'

interface Props { userAgent?: string }

const Home: NextPage<Props> = ({ userAgent }) => (<main>...</main>)

Page.getInitialProps = async ({ req }) => {
  const userAgent = req ? req.headers['user-agent'] : navigator.userAgent
  return { userAgent }
}

export default Page

export const config: PageConfig = {
  api: { bodyParser: false }
}

It would be nice if you could instead declare the export shape of the whole module in one line near the top, like implements NextPageModule<Props>.

Another thought: it would be interesting if there was some way to specify in a TypeScript config that all files matching a certain pattern (like ./pages/**/*.{ts,tsx}) must implement a certain export shape, so a module could have its exports type-checked purely because it's located within the pages directory for example. But I'm not sure if there's any precedent for this approach, and it might get confusing.

I find I'm often tempted to create a Singleton Class when a simple module that implements an interface is all I need. Any tips how best to address this?

orta commented

Thinking about this from a 2020 perspective, I wonder if instead of export implements Showable we re-use type and allow export as an identifier? Today that's invalid syntax so it's unlikely to step on anyone's existing codebase.

Then we get the import syntax:

// Can re-use the import syntax
type export = import("webpack").Config

Declarations are then easy to write:

// Can use normal literals
type export = { test: () => string, description: string }

// Generics are easy
type export = (props: any) => React.SFC<MyCustomModule>

It's also worth thinking what the JSDoc equivalent should be too, maybe:

/** @typedef {import ("webpack").Config} export */
orta commented

There's some notes in ^ - one interesting thing that came out of the meeting was the idea that could we build a more generic tool of which this is a use-case, rather than the only thing it does.

For example, if we had a type assertion operator for type compatibility then that could be used for both the module exports, and generically to verify that types match how you want. For example:

type assert is import("webpack").Config

const path = require('path');

export default {
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  }
};

Where the lack of a target means applying it at the top level scope. This can be used to provide contextual typing (e.g. you'd get auto-complete in the export default { en|

But can also be useful in validating your own types:

import {someFunction} from "./example"

type assert ReturnType<typeof someFunction> is string

It's also worth thinking what the JSDoc equivalent should be too, maybe:

/** @typedef {import ("webpack").Config} export */

I would think @module would be the JSDoc equivalent. The top of the file should have:

/** @module {import("webpack").Config} moduleName */

See: https://jsdoc.app/tags-module.html

Storybook v6 has changed to an approach based on structured modules they called Component Story Format. All .stories.js/ts modules in a codebase are expected to include a default export with type Meta.

Having no way to express this expectation in a global way, combined with the existing deficiency in typing default exports, makes using Storybook v6 with TypeScript a much less smooth experience than it could be.

To add on to @jonrimmer 's points, exporting a default that is of a certain type that replicates a module will lead to issues with tree-shaking.

Webpack has no problem tree shaking import * as Foo. But when you try to do the same with a export default const = {} or export default class ModuleName { with all static members, unused imports aren't removed.

I'm +1ing this whole thread. I'd love to have a way to enforce certain modules export a particular shape similar to the examples cited above such as Next.js and Storybook.

Another use case for this:

Universal modules for React Native & React Native Web, where the bundler for each platform picks the right file for a certain ambiguous import like this:

import {analytics} from './analytics'

File list:

analytics.web.ts
analytics.android.ts
analytics.ios.ts`

Currently, there's no convenient way to enforce that all these files must have the same exports. The absence of module-level type assertion could realistically lead to runtime crashes.

Here's a workaround by using a third file with a useless import/export pair (eg: ModuleValidator.js)

import * as module1Import from '../modules/module1.js';
import * as module2Import from '../modules/module2.js';

/** @type {MyModuleType} */
export const module1Export = module1Import;
/** @type {MyModuleType} */
export const module2Export = module2Import;

The export lines should throw an error if the module fails to implement the interface. Is it pretty? No. But you can easily build tests around this.

@orta, @RyanCavanaugh. Have TS team had any new discussions on this? More and more popular tools and libraries have started to use a file with some specific named exports approach. Storybook and NextJS to name a few. This can provide next-level type safety for such frameworks.

Unlike regular objects, modules can hold types as well as values. Would it be possible annotate a module to specify that it must export a specific type?

For example, we are defining modules for each of our API endpoints. Each module must export types Params and Response. These will be different for each module, but we would like to enforce that each module exports something under those names. Furthermore, if we renamed the types, ideally TS would update the name in every module.

I also want a mechanism to specify that a module implements an interface by using syntax like:

export implements Showable

Here's a stack overflow article with some tips on the subject written by @RyanCavanaugh : https://stackoverflow.com/a/16072725

Hopefully this feedback helps the TS team prioritize work. I'm sure there's lots to do!

EloB commented

Is this doable at all now? I really want to type my Next.js pages. This one has been opened a long time, 8 years... Anyone know the thoughts on TS team opinion on this?

I have found a sorta-workaround.
You will have to assign your module into a variable, or declare a global of the interface's type.

interface IUtilities {
    Version: string;
    Build: number;
    PrintVersion(): string;
}
module Utilities {
    export var Version = "1.1";
    export var Build = 23;
    export function PrintVersion() {
        return `v${Version}b${Build}`;
    }
}
//by assigning it into a var of the interface type, we provoke TypeScript to check the module comply with the interface
var p: IUtilities = Utilities;
EloB commented

@shaipetel thanks for sharing but this doesn’t support default exports?

@EloB the problem with default exports, that you do not include inside a namespace or module - is that the point of import doesn't guarantee to import all members. So can't verify it against an interface, I guess (it is not all or nothing, in a sense of all members/functions or none).
Because of tree shaking I guess - think about it, if you were to verify it as an interface:

  • You export 3-4 functions
  • Your caller imports it and TypeScript says "Your imports are of type X
  • Your caller only uses 2 functions inside
  • Tree shaking will drop the rest unused code << at this point you'll have a problem since TypeScript "lied" about the type you have...

I'm assuming in a nutshell, that's one of the problems. You can't guarantee the entire module will be consumed/exported and won't be broken up by the build.

@shaipetel Typescript is orthogonal to tree-shaking, meaning that it will still Typecheck unused code paths.

@reaktivo exactly, but that is what can cause the problem.
You see, when you import only some members of a module A1 that adheres to interface iA,
then import other members from a different module A2 that also adheres to interface iA.

Now in your code - TypeScript will tell you A1 and A2 both implement iA, right?

So, assume you have a function (not inside your code, maybe a global one. example would be if you try to JSON.stringify your import) that expects iA as a parameter.

TypeScript would basically tell you its ok to send either your imported A1 or A2, without knowing which members you chose to import, and which members are needed by your function, in during dev - TypeScript gives the green light assuming everything would be there.
During build, your A1 is missing some members from iA, and A2 is missing other members...

The build wouldn't know what that function expects, and how it is going to use the parameter - so tree shaking won't be able to identify the members that are needed (dependencies).

I hope I'm making sense...

EloB commented

So by changing tree shaken to follow module requirement (optional) would fix everything?

@EloB disregarding tree shaking, the same could be done for normal modules using

interface IUtilities {
  Version: string;
  Build: number;
  PrintVersion(): string;
}


const Version = "1.1";
const Build = 23;
const PrintVersion = () => `v${Version}b${Build}`;


// by assigning it into a var of the interface type,
// we provoke TypeScript to check the module comply with the interface.
const module: IUtilities = {
  Version, Build, PrintVersion,
};

export default module;
EloB commented

@JarnoRFB Thanks for your time. This doesn't work tree shaking right? I already know that you can write like this but from my understanding it won't work with tree shaking?

@EloB disregarding tree shaking, the same could be done for normal modules using

interface IUtilities {
  Version: string;
  Build: number;
  PrintVersion(): string;
}


const Version = "1.1";
const Build = 23;
const PrintVersion = () => `v${Version}b${Build}`;


// by assigning it into a var of the interface type,
// we provoke TypeScript to check the module comply with the interface.
const module: IUtilities = {
  Version, Build, PrintVersion,
};

export default module;

Yes, but also, we can't simply disregard tree shaking.

@EloB my understanding is unfortunately to limited to comment on that. I just adapted your solution for normal modules.

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

File: YourModule.ts

/* -------------------------------------------------------------------------- */
/*                            Module Type Checking                            */
/* -------------------------------------------------------------------------- */

type _ = typeof import('./YourModule');
const __TYPE_CHECKING__: YourModuleInterface = {} as _;

/* ----------------------------------- --- ---------------------------------- */

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.

EDIT:

Here a more clear (semantical?) variable name example to avoid misinterpretations.

/* myModule.ts */

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

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

@nthypes, thanks for posting this. I'm okay with ugly if it works. However, I must be missing something because this doesn't seem to work for me:

/* commands.ts */
/* -------------------------------------------------------------------------- */
/*                            Module Type Checking                            */
/* -------------------------------------------------------------------------- */

interface YourModuleInterface {
    foo: string;
}


type _ = typeof import('./commands');
export const foo: YourModuleInterface = "hello" as _;

/* ----------------------------------- --- ---------------------------------- */

I also tried this but this also doesn't work.

/* commands.ts */
* -------------------------------------------------------------------------- */
/*                            Module Type Checking                            */
/* -------------------------------------------------------------------------- */

interface YourModuleInterface {
    foo: string;
}


type _ = typeof import('./commands');
export const __TYPE_CHECKING__: YourModuleInterface = { foo: "hello" } as _;

/* ----------------------------------- --- ---------------------------------- */

@ericmasiello the implementation is wrong.

You should not edit __TYPE_CHECKING__ variable value. Only the type to check.

Should error (wrong type):

/* commands.ts */

export interface YourModuleInterface {
    foo: string;
}

export const foo = false

/* -------------------------------------------------------------------------- */
/*                            Module Type Checking                            */
/* -------------------------------------------------------------------------- */

type _ = typeof import('./commands');
const __TYPE_CHECKING__: YourModuleInterface = {} as _;

/* ----------------------------------- --- ---------------------------------- */

Should error (no export):

/* commands.ts */

export interface YourModuleInterface {
    foo: string;
}

const foo = false

/* -------------------------------------------------------------------------- */
/*                            Module Type Checking                            */
/* -------------------------------------------------------------------------- */

type _ = typeof import('./commands');
const __TYPE_CHECKING__: YourModuleInterface = {} as _;

/* ----------------------------------- --- ---------------------------------- */

Should pass:

/* commands.ts */

export interface YourModuleInterface {
    foo: string;
}

export const foo = 'false'

/* -------------------------------------------------------------------------- */
/*                            Module Type Checking                            */
/* -------------------------------------------------------------------------- */

type _ = typeof import('./commands');
const __TYPE_CHECKING__: YourModuleInterface = {} as _;

/* ----------------------------------- --- ---------------------------------- */

PS: I also added a more "semantical" version on my original post.

Got it. Thanks, @nthypes. Adding on to this, the variable named __TYPE_CHECKING__ is arbitrary. You can call it anything you want. At first, I thought there might be some magical/special meaning associated with it, but that's not the case. Here's my updated example with some comments that elaborate on what I believe is happening based on the code you provided.

/*
Inside the file commands.ts
@note we need to import the same file we're in (`commands.ts`) in the module type checking section below
*/

// this defines the interface for our module (commands.ts), i.e., what it must export
export interface CommandsModuleInterface {
    // put whatever you want here for your use case.
   // This use case says we must export a value named `foo` of the type `string`
    foo: string;
}

// here, we implement our interface
export const foo = "hello";

/* -------------------------------------------------------------------------- */
/* Below this line is the "hack" to validate our command.ts module.
/* It works by importing the `type` of the module (file) we're in, i.e., `commands.ts`
/* -------------------------------------------------------------------------- */

// this infers the type of what we're *actually* exporting from `commands.ts`
// we store this as a type called `_` (again this is arbitrary)
type _ = typeof import('./commands');

// The line below here is where the actual type checking occurs.
// We assign an arbitrarily named `const` as `__MODULE_TYPE_CHECK__`
// and specify the type as our desired module interface, `CommandsModuleInterface`.
// We assign the `const` `CommandsModuleInterface` a value of `{}` but immediately
// try to type assert that`__MODULE_TYPE_CHECK__`, which we said should be of type
//  `CommandsModuleInterface`, matches the type actually exported by our module
// and assigned the type `_`.
// @note The eslint-disable-next-line is optional. I needed it for my lint rules
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const __MODULE_TYPE_CHECK__: CommandsModuleInterface = {} as _;

/* ----------------------------------- --- ---------------------------------- */

Also if you want auto-completion with intellisense:

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

export const someMethod = MODULE.someMethod

or

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

export const { someProperty, someMethod } = MODULE;
alloy commented

This is great. I've amended the example slightly to not emit an empty runtime object:

type THIS_MODULE = typeof import('./commands');
type TYPE_CHECK<T extends CommandsModuleInterface> = T;
declare const _: TYPE_CHECK<THIS_MODULE>;

Too bad you have to hard-code the file name in the file itself. There is a high chance of it getting misaligned after renaming/copying.

Too bad you have to hard-code the file name in the file itself. There is a high chance of it getting misaligned after renaming/copying.

If this happens you will receive an type error.

antl3x commented

UPDATE: I'm using a more generic implementation to reduce the amount of boilerplate per file. For those interested:

/* satisfies.ts */

/* -------------------------------------------------------------------------- */
/*                            Module Type Checking                            */
/* -------------------------------------------------------------------------- */
// This is a hack to make sure that the module type is correct
/* eslint-disable */

export const satisfies = <ModuleInterface, TypeOfFile extends ModuleInterface>() => {
  void 0 as TypeOfFile
}
/* UserManagement.ts */
import { satisfies } from 'satisfies'

interface IUserManagement {
  addUser: () => boolean
}

export const addUser = () => true

satisfies<IUserManagement, typeof import('./test')>()

Of course, you can change it to a name that makes the most sense to you: satisfies | assert | implements.