Skip to content
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

All I Want for Christmas Is… These Seven TypeScript Improvements #24

Open
utterances-bot opened this issue Dec 26, 2022 · 17 comments
Open

Comments

@utterances-bot
Copy link

All I Want for Christmas Is… These Seven TypeScript Improvements

Effective TypeScript: All I Want for Christmas Is… These Seven TypeScript Improvements

https://effectivetypescript.com/2022/12/25/christmas/

Copy link

vezaynk commented Dec 26, 2022

Accessing types at runtime will never ever happen as it is an explicit non-goal. The various libraries that attempt to tackle it do a pretty good job. I think tacking into the compiler would be a mistake. The modularity is good.

100% agree on what you call “evolving” types. It’s something OCaml, Flow, and ReScript do very well. Right now, TS only infers types in one direction. It would be pretty helpful if it could infer types from usage not just for the simple cases (n in n++ is a number) but also for functions that curry complex generics. Right now the only way to do it is to copypaste the whole type signature and tweak it. It’s very burdensome, just take a look at react-query-toolkit code.

Another wish that I could tack on is OCaml-like variants. I don’t just want a “number” type, I want to be able to declare a Milliseconds(number) and a Seconds(number) which should not be interchangeably used without going through an appropriate conversion.

Flow had a lot of this, but lost out because instead of trying to help describe real world JS, it attempted to prescribe a different (safer) style of JS which developers largely rejected for being a pain. The ensuing lack of library support effectively killed it.

Copy link

For me a great present from the typescript team would be finally getting some action on this issue that a lot of people desperately want.

microsoft/TypeScript#14419

It would allow type access at compile time, and a lot of those libraries would be able to generate code at compile time, based on types, including potentially generating some kind of RTTI style code.

Copy link

shamsartem commented Dec 26, 2022

outdated (read comments bellow)

I hope this will not give an error in the future:

const logLevels = ["info", "warn", "error"] as const;
type LogLevel = typeof logLevels[number];

const isLogLevel = (unknown: unknown): unknown is LogLevel =>
  logLevels.includes(unknown);
//                      ^? Argument of type 'unknown' is not assignable to parameter of type '"info" | "warn" | "error"'

(also would be interesting to learn if there is a good reason for this to be an error)

@vezaynk
Copy link

vezaynk commented Dec 26, 2022

@shamsartem its an error because much like filter, it doesn't perform type narrowing.

The following doesn't narrow types as expected either:

[1, null].filter(n => !!n) // (number|null)[]

@shamsartem
Copy link

shamsartem commented Dec 26, 2022

@vezaynk I don't even expect it to perform type narrowing. I just expect it to accept any arguments that I through at it. The "type narrowing" part is done manually using unknown is LogLevel

Everything will work if you do it like this:
const isLogLevel = (unknown: unknown): unknown is LogLevel => logLevels.some((level) => level === unknown);

@vezaynk
Copy link

vezaynk commented Dec 26, 2022

@shamsartem Sorry if the example seemed unrelated. The issue is in fact because of the lack of type narrowing. The type signature of includes is something like

includes<T>(this: T[], entry: T): boolean

What you're wishing is for it to have an overload that looks something like this:

includes<T>(this: T[], entry: unknown): entry is T

The trap in this scenario though, is that this becomes legal with no warning from ts:

[1].includes("1")

Pardon any errors, Im typing this on my phone.

@shamsartem
Copy link

@vezaynk for me it would be completely fine if includes type signature was something like this:
includes<T>(this: T[], entry: unknown): boolean. This would solve my issue 100%

@vezaynk
Copy link

vezaynk commented Dec 26, 2022

While it would solve the issue for you, it would create one for me.

If I write

function isNumberOne(arg: string) {
  return [1].includes(arg)
}

TS flags an error that makes my code incorrect. And makes me fix it:

function isNumberOne(arg: string) {
  return [1].includes(+arg)
}
// or
function isNumberOne(arg: number) {
  return [1].includes(arg)
}

The overload would prevent TS from warning me about this incorrect code.

@shamsartem
Copy link

@vezaynk Good point. That seems to be the actual reason it is done like that. Thanks!

Copy link
Owner

danvk commented Dec 27, 2022

Interesting example @shamsartem. I agree with everything @vezaynk said. There are many patterns that "work" in JavaScript that TypeScript chooses to disallow. This is ultimately a judgment call made by the TS team, and in your case the error was a false positive. But there are many other cases where this would catch real errors. You can't have it both ways.

I'm not offended at all by a type assertion in this case (as LogLevel), or by using an any:

const isLogLevel = (val: any): val is LogLevel => logLevels.includes(val);

Another option would be to make your own version of includes that's explicitly loose:

const looseIncludes = (xs: unknown[], x: unknown) => xs.includes(x);

const isLogLevel = (unknown: unknown): unknown is LogLevel =>
  looseIncludes(logLevels, unknown);  // OK

Copy link
Owner

danvk commented Dec 27, 2022

100% agree on what you call “evolving” types. It’s something OCaml, Flow, and ReScript do very well. Right now, TS only infers types in one direction. It would be pretty helpful if it could infer types from usage not just for the simple cases (n in n++ is a number) but also for functions that curry complex generics. Right now the only way to do it is to copypaste the whole type signature and tweak it. It’s very burdensome, just take a look at react-query-toolkit code.

This is what Anders calls "spooky action at a distance" (I believe this is a quote from one of his tsconf keynotes, not sure which year). It is certainly convenient in some cases, though from my brief experience with Flow, I remember that it can make it much, much harder to figure out where the real error is (it's not always where the error is surfaced). "Evolving" types are much narrower in scope.

Another wish that I could tack on is OCaml-like variants. I don’t just want a “number” type, I want to be able to declare a Milliseconds(number) and a Seconds(number) which should not be interchangeably used without going through an appropriate conversion.

There are some patterns around this, though I certainly agree that they can be awkward in practice: https://basarat.gitbook.io/typescript/main-1/nominaltyping

Copy link

For what it's worth, in the example you give, you can have optional generics by declaring a default value:

K extends keyof T = keyof T

This works:

function makeLookup<T, K extends keyof T = keyof T>(k: K): (obj: T) => T[K] {
  return (obj: T) => obj[k];
}

interface Student {
  name: string;
  age: number;
}

const lookupName = makeLookup<Student>('name');
const lookupAge = makeLookup<Student>('age');

console.log(lookupName({"name": "Alice", "age": 23}));
console.log(lookupAge({"name": "Bob", "age": 21}));

Copy link
Owner

danvk commented Jan 5, 2023

@SamirTalwar depends what you mean by "works" :) Your code does type check and it does what you expect at runtime. But the types aren't are precise as you'd like:

// ...
const alice = lookupName({"name": "Alice", "age": 23});
//    ^? const alice: string | number
const age = lookupAge({"name": "Bob", "age": 21});
//    ^? const age: string | number

(playground)

Ideally these would be string and number, not string | number (which is T[keyof T]).

The issue with default values for type parameters is that K will always be bound to keyof T if you set T explicitly. It won't be inferred from the k parameter unless you use one of the techniques discussed in my previous blog post.

Copy link

@danvk: Thanks for the clarification! Now I get it.

@vezaynk
Copy link

vezaynk commented Jan 6, 2023

For what it's worth, you can actually implement a makeLookup nicely without optional generics:

function makeLookup<K extends string>(k: K) {
  return function lookup<T extends { [key in K]: any }>(obj: T) {
    return obj[k];
  }
}

const lookupName = makeLookup('name');
const lookupAge = makeLookup('age');

const alice = lookupName({"name": "Alice", "age": 23});
//    ^? const alice: string
const age = lookupAge({"name": "Bob", "age": 21});
//    ^? const age: number

playground

@vezaynk
Copy link

vezaynk commented Jan 6, 2023

And if you want explicitly bind to it to Student, you can do the following:

function makeLookup<E>() {
 return function <K extends string>(k: K) {
  return function lookup<T extends { [key in K]: any } & E>(obj: T) {
    return obj[k];
  }
}
}

interface Student {
  name: string;
  age: number;
}

const lookupName = makeLookup<Student>()('name');
const lookupAge = makeLookup<Student>()('age');

const alice = lookupName({"name": "Alice", "age": 23});
//    ^? const alice: string
const age = lookupAge({"name": "Bob", "age": 21});
//    ^? const age: number


const missingAge = lookupName({"name": "Bob" }); // Argument of type '{ name: string; }' is not assignable to parameter of type '{ name: any; } & Student'

The trick to working around optional generics is by passing in one at a time. In this case we pass in Student separately from the rest. Not saying a real optional generics implementaiton wouldn't be nice, but it is workable.

@danvk
Copy link
Owner

danvk commented Jan 7, 2023

@vezaynk Yes, currying is the standard workaround for this issue. You can also use a class, see my blog post from 2020.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants