danvk / effective-typescript

Effective TypeScript 2nd Edition: 83 Specific Ways to Improve Your TypeScript

Home Page:https://effectivetypescript.com

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

All I Want for Christmas Is… These Seven TypeScript Improvements

utterances-bot opened this issue · comments

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/

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.

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.

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)

@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)[]

@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);

@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.

@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%

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.

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

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

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

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}));

@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.

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

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

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.

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