microsoft / microsoft/TypeScript

Allow identifiers in types to resolve to unit-typed values

オープン
#29,130 コメント 0 件 リアクション 4 件 担当者 0 名 GitHub で見る

まだ誰も着手していません。

Awaiting More Feedback Suggestion
主要言語
Go
スター
111k
フォーク
14.4k
平均マージ
1日 19時間
マージ済み PR(30日)
117

説明

Search Terms

constant type literal, Cannot find name, type identifier, unit value

Suggestion

When declaring a constant like const UNSPECIFIED = "UNSPECIFIED";, I'd like the identifier UNSPECIFIED to be usable as a type (equivalent to the "UNSPECIFIED" literal type), just like how you can use null, string literals, and enum values as types. There are lots of workarounds and alternatives, but all of them have caused confusion or unease within my team, and I think it's possible to make this straightforward syntax work.

As mentioned in https://github.com/Microsoft/TypeScript/issues/6151#issuecomment-165840507 , simply declaring UNSPECIFIED in the type namespace would be a breaking change, so my suggestion is to instead extend type name resolution to look in the value namespace as a fallback, which is backcompat.

Here's my attempt at formalizing this behavior:

  • When resolving an identifier node in a type context, let ID be the identifier's name.
    • First search the type namespace for the name ID. If a type with that name exists, use it.
    • If no type matched, then search the value namespace to see a value exists with name ID.
      • If a value matched, determine the value's type.
        • If the type of ID is a unit type (string literal, enum value, etc), then treat this node as if it was written typeof ID.
        • If ID's type is not a unit type, give the error message "Cannot use non-unit value ID as a type".
      • If no value matched, give the error message "Cannot find name ID".

(I understand that there may be implementation roadblocks with this approach, just throwing it out there as a suggestion in case it is reasonable like I'm hoping.)

Some related improvements that would also be nice:

  • Simply improving the error message from Cannot find name "UNSPECIFIED" to something like "UNSPECIFIED" is a value, not a type" would be really helpful. This is already filed as https://github.com/Microsoft/TypeScript/issues/27630 . It would be especially helpful if the error message pointed to an officially-recommended workaround.
  • In addition to changing identifier resolution, ideally more complex expressions could also be resolved in this way, particularly property accesses. In my example in https://github.com/Microsoft/TypeScript/issues/17592#issuecomment-449440944 , ideally Events.Pause would be usable as a type, and if it was, there would be a pattern for a "union enum" without special language support. It would also allow accessing constants from nested objects (as long as the constant has a literal type) and import * statements. You could even go so far as to say "any expresssion E is a shorthand for typeof E", though I think for complex expressions like function invocations, it could be confusing.

This proposal requires the relevant values to have literal types, which is mostly true already. Some prior discussions and future suggestions around that: https://github.com/Microsoft/TypeScript/issues/6167, https://github.com/Microsoft/TypeScript/pull/10676, https://github.com/Microsoft/TypeScript/issues/26979 .

Use Cases

See the code snippets below for concrete use cases of literal types in general, though I imagine those are fairly well-understood.

My team is in the process of everyone learning TypeScript and porting a large JavaScript codebase to TypeScript, and this has been one of the bigger issues we've run into. The general advice we've concluded on is "TypeScript doesn't handle constants as types very well; enums work a lot better".

Current approaches and their shortcomings
Use an enum

Switching from enum-style constants to actual enums is helpful, but there are some shortcomings:

  • This requires changing all usages, including JS files using these constants. We prefer to keep emitted code changes to a minimum when porting JS to TS, and having to update all usages introduces risk.
  • It's awkward to make a one-value enum, like with the UNSPECIFIED example. We won't always want a list of choices, sometimes we just want a single special value that we can compare against.
  • Since enum values are always written as a property access, they're not as concise as constants.
Use typeof UNSPECIFIED as the type

For example, you could write type MaybeFolder = Folder | typeof UNSPECIFIED. This works, but has been confusing enough in practice that I think it's best to avoid, mostly because people feel that typeof UNSPECIFIED should be string. It generally has a feel of advanced/mysterious TypeScript trickery. It makes sense if you have deep knowledge of how TypeScript works, but it's not intuitive or clear.

Explicitly define both a value and a type

There are a few ways to write it, but here's one example:

export const UNSPECIFIED = "UNSPECIFIED";
export type UNSPECIFIED = typeof UNSPECIFIED;

This is nice in that it's self-contained, but it's doubly-confusing because not only does it use the typeof trick, it uses the surprising fact that you can export the same name twice, once in the value namespace and once in the type namespace. We're currently using this pattern for a small number of shared constants, but it seems awkward for people to write new instances of it.

Always use a string literal type instead of a constant value

Just using "UNSPECIFIED" as the type works out pretty well, but has some disadvantages:

  • It doesn't work as well with editor tooling like jump-to-definition.
  • It feels wrong/unsafe/ugly, especially to someone not as familiar with TypeScript.
Use null

In some cases, null can be used to denote an alternate value, e.g. Folder | null. This is concise, but makes the intentions less clear.

Examples

This can be used to concisely create a safer null variant:

interface Folder {
  name: string;
}
const NO_ACCESS = "NO_ACCESS";

function formatFolder(folder: Folder | NO_ACCESS): string {
  if (folder === NO_ACCESS) {
    return "(Insufficient permissions)"
  } else {
    return folder.name;
  }
}

Similarly, it can be used to extend enums with ad-hoc values:

enum ColorOption {
  RED = "RED",
  GREEN = "GREEN",
  BLUE = "BLUE",
}

const UNSPECIFIED = "UNSPECIFIED";

interface ColorPickerUIState {
  colorChoice: ColorOption | UNSPECIFIED;
}

It can also be used for enum-like use cases for legacy code that doesn't yet use enums:

const RED = "RED";
const GREEN = "GREEN";
const BLUE = "BLUE";
const ORANGE = "ORANGE";
const YELLOW = "YELLOW";
const PURPLE = "PURPLE";

type PrimaryColor = RED | GREEN | BLUE;

To demonstrate a few examples of name resolution:

const number = true;
// "number" refers to plain number type, since type names always take precedence.
const x: number = 3;

type T1 = string;
type T2 = string;

function foo() {
  const T2 = "T2";
  const T3 = "T3";
  
  // Param type resolves to string | string | "T3".
  function f(val: T1 | T2 | T3) {}
}

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.

コントリビューションガイド

コントリビューションガイドを開く

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

調査の方向性

この issue にはファイル、テスト、エントリーポイントの指定がありません。まず、TypeScript の型コンテキストにおける識別子解決と、literal 型および enum 型のテストを見つけます。完了条件は、unit types を持つ value namespace の識別子が型として解決され、non-unit の値および存在しない値には指定されたエラーが返されることです。

索引モデルが issue の本文から書いたものです。

評価

技術スタック
typescript
領域
compilers
issue の種類
機能追加
難易度
5/5
見積もり時間
1週間以上
活発さ
停滞
明瞭さ
おおむね明確
初心者へのやさしさ
25/100

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。