TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

Implement partial type argument inference using the _ sigil

Open weswigham opened this issue 5 years ago • 97 comments

In this PR, we allow the _ sigil to appear in type argument lists in expression positions as a placeholder for locations where you would like inference to occur:

const instance = new Foo<_, string>(0, "");
const result = foo<_, string>(0, "");
const tagged = tag<_, string>`tags ${12} ${""}`;
const jsx = <Component<_, string> x={12} y="" cb={props => void (props.x.toFixed() + props.y.toUpperCase())} />;

This allows users to override a variable in a list of defaulted ones without actually explicitly providing the rest or allow a type variable to be inferred from another provided one.

Implements #26242. Supersedes #23696.

Fixes #20122. Fixes #10571.

Technically, this prevents you from passing a type named _ as a type argument (we do not reserve _ in general and don't think we need to). Our suggested workaround is simply to rename or alias the type you wish to pass. Eg,

interface _ { (): UnderscoreStatic; }

foo<_>(); // bad - triggers partial type inference, instead:

type Underscore = _;
foo<Underscore>(); // good

we did a quick check over at big ts query, and didn't find any public projects which passed a type named _ as a type argument in an expression/inference position, so it seems like a relatively safe care-out to make.

Prior work for the _ sigil for partial inference includes flow and f#, so it should end up being pretty familiar.

weswigham avatar Aug 10 '18 01:08 weswigham

Why write infer explicitly? Could we do like destructuring? <, string> instead of <infer, string>

By default, it would infer.

alfaproject avatar Aug 10 '18 05:08 alfaproject

@alfaproject I wrote my rationale down in #26242

weswigham avatar Aug 10 '18 07:08 weswigham

Would this PR enable this scenario? I didn't see a test quite like it. Basically extracting an inferred type parameter from a specified type parameter.

type Box<T> = { value: T };

type HasBoxedNumber = Box<number>;

declare function foo<T extends Box<S>, S>(arg: T): S;

declare const hbn: HasBoxedNumber;

foo<HasBoxedNumber, infer>(hbn).toFixed();

jwbay avatar Aug 14 '18 23:08 jwbay

Based on the design meeting feedback, this has been swapped to variant 2 from the proposal - using the * sigil as a placeholder for inference. We'll need updates to our tmlanguage to get syntax highlighting right (although we already parsed * in type positions for jsdoc, so we probably should have already).

weswigham avatar Aug 17 '18 21:08 weswigham

Would this PR enable this scenario? I didn't see a test quite like it. Basically extracting an inferred type parameter from a specified type parameter.

As is, no. Other type parameters (supplied or no) are not currently inference sites for a type parameter. We could enable it here (just by performing some extra inferType calls between the supplied types and their parameters' constraints), probably, but... should we? @ahejlsberg you have an opinion here?

weswigham avatar Aug 17 '18 21:08 weswigham

@ahejlsberg you have an opinion here?

I don't think we want constraints to be inference sites, at least not without some explicit indication. At some point we might consider allowing infer declarations in type parameter lists just as we do in conditional types:

type Unbox<T extends Box<infer U>> = U;

Though you can get pretty much the same effect with conditional types:

type Unbox<T extends Box<any>> = T extends Box<infer U> ? U : never;

ahejlsberg avatar Aug 17 '18 21:08 ahejlsberg

Alright, I'll leave this as is then and just mention that it's available as a branch if we ever change our minds in the future.

weswigham avatar Aug 17 '18 22:08 weswigham

@weswigham It seems inconsistent (and kinda strange) to use the * sigil for this when we already use the infer keyword to denote explicit type inference...

type Tagged<O extends object, T> = O & { __tag: T };

// "Infer a type, and make it available under the alias 'T'"
declare function getTag<O extends Tagged<any, any>>(object: O): O extends Tagged<any, infer T> ? T : never;

// "Infer a type, and make it available to 'getTag' under the alias at the first type position"
getTag<infer>({ foo: string, __tag: 'bar' })
// => 'bar'

This seems like an obvious syntactic duality to me... What was the reason you instead decided to go with *?

treybrisbane avatar Aug 18 '18 09:08 treybrisbane

The existing infer T keyword produces a new binding for T; this wouldn't be available in argument positions (e.g. you can't write getFoo<infer T, T>()). Having the infer keyword have arity 1 in conditional types and arity 0 in type argument positions seems like a decrease in overall consistency rather than an increase.

RyanCavanaugh avatar Aug 21 '18 16:08 RyanCavanaugh

It would probably be nice to be able to declare infer on the functions, ex: function foo<A, B = infer>(b: B, c: SomeComplexType<A,B>): SomeOtherComplexType<A,B>

KyleDavidE avatar Aug 22 '18 17:08 KyleDavidE

@RyanCavanaugh

Having the infer keyword have arity 1 in conditional types and arity 0 in type argument positions seems like a decrease in overall consistency rather than an increase.

Thanks for the response. :)

Fair enough, but I'd argue that this decrease in consistency is far less than that of introducing an entirely new sigil for this purpose. Is there really a benefit to users in using such a radically different syntax for something whose only difference to infer T is the arity?

treybrisbane avatar Aug 23 '18 00:08 treybrisbane

Something else to consider is that TypeScript supports JSDoc, and * in JSDoc means any. I'm not sure it's a good idea to reuse a symbol that means any in one context for something that means "please infer this type for me" in another context.

If we're concerned about making operators/keywords context-sensitive, then again it seems like making infer context-sensitive is far less of an evil than doing the same for *.

treybrisbane avatar Aug 23 '18 00:08 treybrisbane

I don't mind * as it jives with flow. Users of typescript can just avoid * in jsdoc and always use any for the purpose easily enough?

I'd also like to see this:

const instance = new Blah<T, **>(1, 'b', false, new Date())

I have a class that bundles many string literal types and I have to enumerate them all at every callsite even when I'm using the code from this branch. Everytime I add a new string literal I have to update every single callsite which is a massive drag ;)

insidewhy avatar Aug 31 '18 16:08 insidewhy

Consider:

type LiteralMap<S1 extends string, S2 extends string, S3 extends string> = {
  item1: S1,
  item2: S2,
  item3: S3
}

With this feature at every definition using this type I have to use:

function user(map: LiteralMap<*, *, *>) {}

Now if I need to add a new literal to my map I have to update this to:

type LiteralMap<S1 extends string, S2 extends string, S3 extends string, S4 extends string> = {
  item1: S1,
  item2: S2,
  item3: S3,
  item4: S4,
}

which is no big deal, but now I also have to update every single use of this to:

function user(map: LiteralMap<*, *, *, *>) {}

With LiteralMap<**> I can just update the definition without affecting every area it is used.

insidewhy avatar Sep 01 '18 10:09 insidewhy

Or it could follow the tuple system

type LiteralMap<S1?, S2?, S3?> = {
  item1: S1,
  item2: S2,
  item3: S3
}

function user(map: LiteralMap) {} // infer, infer, infer
function user(map: LiteralMap<boolean>) {} // boolean, infer, infer
function user(map: LiteralMap<_, boolean>) {} // infer, boolean, infer

type LiteralMap<S1, S2, S3?> = {
  item1: S1,
  item2: S2,
  item3: S3
}

function user(map: LiteralMap) {} // not allowed, S1 and S2 missing
function user(map: LiteralMap<boolean>) {} // not allowed, S2 missing
function user(map: LiteralMap<_, boolean>) {} // infer, boolean, infer

alternatively it could use the default assignation (which I guess makes more sense, since if you want it to infer the default type makes no sense?)

type LiteralMap<S1 = _, S2 = _, S3 = _> = {
  item1: S1,
  item2: S2,
  item3: S3
}

function user(map: LiteralMap) {} // infer, infer, infer
function user(map: LiteralMap<boolean>) {} // boolean, infer, infer
function user(map: LiteralMap<*, boolean>) {} // infer, boolean, infer

type LiteralMap<S1, S2, S3 = _> = {
  item1: S1,
  item2: S2,
  item3: S3
}

function user(map: LiteralMap) {} // not allowed, S1 and S2 missing
function user(map: LiteralMap<boolean>) {} // not allowed, S2 missing
function user(map: LiteralMap<_, boolean>) {} // infer, boolean, infer

xaviergonz avatar Sep 01 '18 23:09 xaviergonz

Much better than #23696. Great stuff 👍 Thanks!

niieani avatar Sep 12 '18 07:09 niieani

@RyanCavanaugh What the problem with this PR? Is this too complicated? I wait this feature more than 2 years. I really need this one in a lot of places. Currently I need to use dirty hacks like

function x<A>() { return <B>(prop: {a: A, b: B})=>void}
x<number>()({a:1, b:'b'})

cevek avatar Sep 20 '18 10:09 cevek

Does this PR would allow to infer the type inside helper types? eg:

const obj: Readonly<*> = { hello: 'world' } // obj is type `{ readonly hello: 'world' }`;

michaeljota avatar Oct 08 '18 18:10 michaeljota

This proposal has been updated to use _ as the sigil following discussion today and investigation of some prior (and concurrent) work in the area.

weswigham avatar Nov 02 '18 22:11 weswigham

Will this allow me to enforce an object with all values of one type and still do a keyof on the keys and get the custom key names?? Something like this:

const myobj : Record<_,MyValue> = {
/* .... */
}

type keys = keyof typeof myobj;

arogg avatar Nov 06 '18 05:11 arogg

Will this allow me to enforce an object with all values of one type and still do a keyof on the keys and get the custom key names??

Not at present, no - as is, this only allows partial inference at places where we already do inference - the top level of expression type argument lists.

weswigham avatar Nov 06 '18 05:11 weswigham

Just to be sure, this is not an existential operator, right?

michaeljota avatar Nov 06 '18 14:11 michaeljota

And again, it moves to the unknown future release... :(

cevek avatar Nov 08 '18 20:11 cevek

Why can't we do the inference the way C# does it?

Foo(0, "test");

// is same as

Foo<int, string>(0, "test");

Why do we need underscores?

fahadash avatar Nov 19 '18 15:11 fahadash

@fahadash the key word is partial

sledorze avatar Nov 19 '18 16:11 sledorze

What was the reasoning for the change * -> _. I understand that it's familiar to people coming from, say, scala, but <*> is used in java so it's not like It'll be alien to everyone.

Put that aside, the thing that doesn't fly with me the most is that _ is not a reserved word. So in principle, it could be a name (like lodash can choose to export a type of that name even if we know they're not). Ideally, I'd prefer the sigil to be the same to the smart pipe's placeholder, if that's ever gonna happen. I don't know If I prefer the one they currently propose which is #, but it seems likely to be the choice moving forwards given it's already used in the private proposal. What I'm pretty certain is that the smart pipe or any other placeholder that's gonna come in the future will not use _ for compatibility reasons.

sonhanguyen avatar Dec 11 '18 00:12 sonhanguyen

Put that aside, the thing that doesn't fly with me the most is that _ is not a reserved word.

We introduce new global type names and contextual keywords in type contexts pretty often. Provided we don't break many people, we're fine with it (and it's not like we'll be breaking expression usages). Plus, we got prior art in the JS space - flow added _ as the placeholder for partial inference.

Ideally, I'd prefer the sigil to be the same to the smart pipe's placeholder, if that's ever gonna happen.

Total aside, but I, personally, have a strong dis-preference to any pipe proposal that introduces a context variable. Powershell and other shell languages made me come to hate context vars.

weswigham avatar Dec 11 '18 00:12 weswigham

this message brought to you by my $_;

Jessidhia avatar Dec 11 '18 00:12 Jessidhia

What about extending _ and also using for explicit casting to expected type? For example we need unsafe cast number to boolean like:

function assert(value: boolean, msg?: string) {
  if (!value) throw new Error(msg || 'assert failure');
}
const value = 1;
assert(value as any);

instead of that we might write this:

const value = 1;
assert(value as _);

And _ in this case try inferring to destination type of function argument and fallback to any if this not possible.

MaxGraey avatar Jan 16 '19 00:01 MaxGraey

@MaxGraey Although I can see what you mean, I think that's not a good example because atm if you try casting value to boolean TS will report an error (I get Conversion of type 'number' to type 'boolean' may be a mistake because neither type sufficiently overlaps with the other), so in that case it would just cast to any, making it a bit hard to see an added value. as _ just quickly becomes an alias of as any

This example might be a bit better:

interface Foo {
    value: number
}

function increment(foo: Foo) {
    foo.value++;
}

increment({
    value: 1,
    bar: 'bar'
}); // Fails: Object literal may only specify known properties, and 'bar' does not exist in type 'Foo'.

increment({
    value: 1,
    bar: 'bar'
} as Foo); // Workaround

const obj = {
    value: 1,
    bar: 'bar'
}
increment(obj); // Also works.

Link to playground

In this case, we can argue whether we can just cast it to _ instead, and the compiler should already know that you can cast this object to Foo, because it's what the parameter expects.

But then on this case, isn't it a bit weird that casting obj to Foo works when using the declared obj variable and it doesn't when inlining it? Maybe this should be thought as a different issue instead.

voliva avatar Jan 17 '19 08:01 voliva