Skip to content

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

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

Open
5 tasks done
jonaskello opened this issue Feb 9, 2020 · 2 comments
Open
5 tasks done

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

jonaskello opened this issue Feb 9, 2020 · 2 comments
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@jonaskello
Copy link

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:

  • 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.
@jonaskello
Copy link
Author

jonaskello commented Feb 9, 2020

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";

@RyanCavanaugh RyanCavanaugh added In Discussion Not yet reached consensus Suggestion An idea for TypeScript labels Feb 21, 2020
@Conaclos
Copy link

Conaclos commented Jun 30, 2021

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.

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

3 participants