microsoft / TypeScript

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

Let (value-space) `typeof` give a more specific type than the union of all `typeof` types #46399

Open JoshuaKGoldberg opened 2 years ago

JoshuaKGoldberg commented 2 years ago

Suggestion

🔍 Search Terms

typeof typeof string literal value redeclare

List of keywords you searched for before creating this issue. Write them down here so that others can find this suggestion more easily and help provide feedback.

✅ Viability Checklist

My suggestion meets these guidelines:

⭐ Suggestion

If a value is known to be a particular primitive type or union of types, such as string or number | string, typeof typeof should give back the exact string literal type, such as "string" or "number" | "string", respectively.

In other words: TypeScript should give a more specific type than just string if typeof <value> would be known to give back one of >=1 string literals.

📃 Motivating Example

When a variable is meant to store the result of a typeof expression, TypeScript should be able to infer its string literal type, not just that it's a string primitive.

There should be no errors in this code:

let myTypeOf: 'number' | 'string';
myTypeOf = typeof 'abc';
myTypeOf = typeof 123;

Today:

Type '"string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function"' is not assignable to type '"string" | "number"'.
  Type '"bigint"' is not assignable to type '"string" | "number"'.

💻 Use Cases

See this variable declaration: https://github.com/typescript-eslint/typescript-eslint/pull/3891/files#diff-8a7d4566dbe9356145e8b86ccefa6d7a164c0b77818beb78d789e1089e9c4bc4R39

Ideally we would want to call it let memberType: 'noInitializer' | 'number' | 'string' | undefined, but later on typeof member.initializer.value gives back string instead of 'number' | 'string'.

Edit: this also causes https://github.com/typescript-eslint/typescript-eslint/issues/3453, so perhaps this borders on a bug?

RyanCavanaugh commented 2 years ago

Previously discussed at https://github.com/microsoft/TypeScript/issues/16314

DanielRosenwasser commented 2 years ago

Notes being taken, but some sample code as food for thought.

// Issues With Defensive Nullish Checks
function foo(x: number) {
    // Allowed ✔️
    if (x === undefined) {
        throw new Error("No! x must not be undefined.");
    }
    // Error? ❌
    if (typeof x === "undefined") {
        throw new Error("No! x must not be undefined.");
    }

    // Happens with function args, array accesses, etc.
}

// Issues With Structurally Assignable Apparent Types
function bar(x: {}, y: { toString(): string }) {
    // Allowed?
    if (typeof x === "number") {
        // ...
    }

    // Allowed?
    if (typeof y === "boolean") {
        // ...
    }
}
bradzacher commented 2 years ago

I could understand that the primitives - boolean, number, string are narrowed easily, but there is still the issue of defensive programming - eg how do you allow typeof x === 'undefined' if x was narrowed to 'number'? It's not as clear cut from a type-system POV as x === undefined. In that case ofc TS can just "allow" this "unnecessary" check because undefined is a unique type, but 'undefined' is not at all.

I think there's still unfortunately a non-trivial number of codebases that use typeof x === 'undefined' checks instead of x === undefined due to legacy standards, so it's probably not something you could just "ignore". Perhaps a flag to union 'undefined' in to the typeof? But that really does just smell from an API design perspective.

I'm personally of the opinion that your types should match reality - and defensively programming contrary your well-defined types is smelly - but I also understand that not every codebase can or should work that way.


Yeah the structural typing model that TS runs on does make the "object" case a lot more complicated to reason about because an "object" type isn't necessarily an "object".

One could argue that the most "sound" thing to do is to not narrow the type at all - but that would likely be surprising to most people that don't have a good knowledge of TS's type system.

If you instead treated it as an object, then it's being based off an assumption that (at the vast scale of TS) likely wouldn't hold true in some codebase - which means you're surprising a different class of people.

So I guess this doesn't really pass the "feature that won't surprise anyone" test because there are certain things which might be surprising if you don't understand TS's type systems?

RyanCavanaugh commented 2 years ago

I guess this doesn't really pass the "feature that won't surprise anyone" test because there are certain things which might be surprising if you don't understand TS's type systems?

I would go further and say that even if you do understand TS's type system, the results here will still look surprising/buggy unless you really think about it in a defensive way, which is the sort of behavior that people frequently complain about as unintuitive.

We haven't even mentioned yet how "function" should be in the results for almost any object type, which would be a further point of confusion.