microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.64k stars 12.44k forks source link

Let type identifiers co-exist with import namespace identifiers #36704

Open jonaskello opened 4 years ago

jonaskello commented 4 years ago

Search Terms

Modules, export, import

Suggestion

It would be convienent if type identifiers could co-exist with import namespace identifiers.

Use Cases

A common pattern is to have a module where the module name is the same as the main type exported by that module.

Examples

Consider this module:

result.ts

export type Result<TError, TValue> =
  | { readonly type: "Ok"; readonly value: TValue }
  | { readonly type: "Err"; readonly error: TError };

export function Ok<TValue>(value: TValue): Result<never, TValue> {
  return { type: "Ok", value };
}

export function Err<TError>(error: TError): Result<TError, never> {
  return { type: "Err", error };
}

// .. more functions to work with the Result type

Now consider a consumer of this moudule:

import * as Result from "./result";

export function itsOk(): Result.Result<string, string> {
  const ok: Result.Result<string, string> = Result.Ok("");
  const err: Result.Result<string, string> = Result.Err("");
  return ok;
}

There are execessive type annotations in this example but the point is that it is annoying ot have to refer to the type Result as Result.Result. I would like to refer to the type as only Result but still have functions related to the module in a Result namespace.

I was reading about the new import type syntax in 3.8 and thought I might get away with something like this:

import * as Result from "./result";
import type { Result } from "./result";

export function itsOk(): Result<string, string> {
    const ok: Result<string, string> = Result.Ok("")
    const err: Result<string, string> = Result.Err("")
    return ok;
}

This fails because there are duplicate identifiers. Typescript does allow duplicate identifiers that live either 100% in the type world or 100% in the concrete world. But imported namespace objects (import * as) lives in both worlds so it does not currently work. Specifically namespace object can contain both types and concrete things.

However I think typescript should be able to infer from usage if I'm referring the type or the namespace when using it in a place where types can be used. If what is referenced could be found by looking at the usage then duplicate identifiers could be allowed. I believe some other languages does it this way.

In this example the type used does not have a dot so it is referring to the imported type rather than the imported namespace.

const ok: Result<string, string> = ..

In this example the type used has a dot so it is referring to the namespace object.

const ok: Result.OtherType = ..

I think this is not related to the ECMA standard for modules and import/export but rather something typescript decide how to handle because it is fully in the "type world" which gets erased when emitted to js.

Checklist

My suggestion meets these guidelines:

jonaskello commented 4 years ago

Note that the solution does not necessarily need to use import *. Some other way to allow a namespace to co-exist with a type would also cover it. In simple cases like the Result example above where the module export a single type it is actually possible to make it work today by using an intermediate file like this:

index.ts

import * as ResultNs from "./result";
export const Result = ResultNs;
export type Result<T1, T2> = ResultNs.Result<T1, T2>;

And in the consuming module you can use it like this:

import { Result } from "./index";

export function itsOk(): Result<string, string> {
  const ok: Result<string, string> = Result.Ok("hello");
  const err: Result<string, string> = Result.Err("hello");
  return ok;
}

Notice how the consumer can use Result when referring to the type. This is really nice, however it breaks down if the result module would export a second type as referring to other types like Result.OtherType will not work (since Result now is a const and not a namespace).

In this case it would be nice if we could do something like this in the intermediate file:

index.ts

export * as Result from "./result";
export type { Result } from "./result";
Conaclos commented 3 years ago

Some additions and a workaround :)

TypeScript already accepts some form of co-existance.

For example, the next code is valid :

export type Result<TError, TValue> =
  | { readonly type: "Ok"; readonly value: TValue }
  | { readonly type: "Err"; readonly error: TError }

export const Result = {
    Ok<TValue>(value: TValue): Result<never, TValue> {
        return { type: "Ok", value }
    },
    Err<TError>(error: TError): Result<TError, never> {
        return { type: "Err", error }
    }
}
import { Result } from "./result"

const x: Result<string, string> = Result.Ok("")

However, the code cannot be tree-shaked.

Curiously, TypeScript also accept this code:

export type Result<TError, TValue> =
  | { readonly type: "Ok"; readonly value: TValue }
  | { readonly type: "Err"; readonly error: TError }

export { Ok, Err } from "./result-functions"

But import only the type :

import { Result } from "./result"

const x: Result<string, string> = Result.Ok("")
// 'Result' only refers to a type, but is being used as a value here.

To resolve this issue, we can create an indirection:

export type Result<TError, TValue> =
  | { readonly type: "Ok"; readonly value: TValue }
  | { readonly type: "Err"; readonly error: TError }

import * as ResultFunctions from "./result-functions"
export const Result = ResultFunctions

I discover this workaround several years ago. However I am still afraid of a regression in a next version of TypeScript. It could be great whether the design team could clarify the situation and provide a full support for co-existance.