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

Rest type #13470

Closed
wants to merge 18 commits into from
Closed

Rest type #13470

wants to merge 18 commits into from

Conversation

sandersn
Copy link
Member

@sandersn sandersn commented Jan 13, 2017

Add rest types. They implement the semantics of object rest as detailed in the second part of the proposal #10727. I have copied and updated that proposal here.

Notes:

  1. Rest types are not difference types and are not usable as the constraint of a mapped type.
  2. The nested rest-type relationships are not implemented yet because I'm not sure if they are worthwhile.

Rest types

The rest type is the opposite of the spread type. It types the TC39 stage 3 object-rest destructuring operator. The rest type rest(T, 'a' | 'b' | 'c') represents the type T after the properties a, b and c have been removed, as well as call signatures and construct signatures.

A short example illustrates the way this type is used:

/** JavaScript version */
function removeX(o) {
  let { x, ...rest } = o;
  return rest;
}

/** Typescript version */
function removeX<T extends { x: number, y: number }>(o: T): rest(T, 'x') {
  let { x, ...rest }: T = o;
  return rest;
}

Syntax

The syntax is rest(T, U) where T is any type and U is string or a union of string literals (including never and single string literals).

Type Relationships

  • rest(A, never) is not equivalent to A because it is missing call and construct signatures.
  • rest(A | B, 'a') is equivalent to rest(A, 'a') | rest(B, 'a').
  • rest(A, string) is equivalent to {}.
  • rest(rest(A, 'a'), 'b') is equivalent to rest(A, 'a' | 'b') and therefore also to rest(rest(A, 'b'), 'a').

Assignment compatibility

  • rest(T, 'x') is not assignable to T.
  • T is assignable to rest(T, 'x') because T has more properties and signatures.
  • rest(T, V) is assignable to rest(U, W) if T is assignable to U and V is assignable to W.

Properties and index signatures

The type rest(A, P) removes P from A if it exists. Otherwise, it does nothing. A retains its index signatures unless P is string.

Call and Construct signatures

rest(A) does not have call or construct signatures.

Precedence

Rest types are just below the keyof operator in precedence.

@Igorbek
Copy link
Contributor

Igorbek commented Jan 13, 2017

Suggestion:

  • rest(T, keyof U) is equivalent to {} when U is assignable to T

@tinganho
Copy link
Contributor

tinganho commented Jan 13, 2017

Doesn't this break some design consistency?

All value argument lists are now delimited with braces (). And all type argument lists are now delimited with brackets <>.

This PR adds a rest keyword and a type argument list delimited with ().

Since, I regard Pick<T>, Partial<T>, Readonly<T>as type operators. I would rather see Rest<T, 'x', 'y'>.

Previously, rest types were only allowed to be identifiers by mistake.
Also remove unneeded parsing support for rest types that got checked in by mistake.
1. Object rest checking allows nested generics now.
2. Use getLiteralTypeFromText instead of createLiteral in order to
intern literals correctly.
@sandersn
Copy link
Member Author

sandersn commented Jan 14, 2017

@Igorbek I didn't call it out sufficiently in the proposal text above, but the second argument of rest(T, U) can not be generic. It has to be a string or string literal union [1]. So there's no way to construct a generic rest(T, keyof U). And there's no other way to construct a string union that covers all possible properties of T.

[1] The reason that the second argument is not generic is that it's impossible to produce a generic object rest — you have to hard-code the names of properties that will be removed in the object destructuring/binding.

@tinganho I don't have a strong design opinion one way or another on the syntax. The only constraint is that we expect rest and spread types to be used rarely, especially rest types, so I don't want to use (for example) T - U as the syntax, because we'll probably want to use that for something else some day.

How about we gauge the community's preference by using 👍 to your comment to indicate Rest<T, 'x' | 'y'>, 👎 to indicate rest(T, 'x' | 'y') and 😕 to indicate something else. I see we already have 2 more votes for Rest.

@rozzzly
Copy link

rozzzly commented Jan 14, 2017

interface FooBar {
    foo: string;
    bar: string;
}
interface Foo {
    foo: string;
}
interface Bar {
    bar: string;
}

would Rest<FooBar, keyof Foo> be equivalent to Bar?

@tinganho
Copy link
Contributor

@sandersn thanks for your comment. Sorry, that I didn't discovered your initial proposal earlier(I just read the spread part).

I think, I understand why you use lowercase rest here. I guess, it is to indicate an intrinsic type operator. Though, I don't really, like the special casing of the type system here. Even if we use capitalized Rest<T, 'x', 'y'>. May I suggest something more of a generic solution that might cover this special case? I have thought about it for months, but haven't had the time to submit it yet.

@Igorbek
Copy link
Contributor

Igorbek commented Jan 14, 2017

@sandersn I see what you mean. But isn't that the case for computed properties?
Currently this works, however is not properly typed:

const b = "b";
let c = { [b]: b, a: 10 }; // c is { [x: string]: string | number, a: number; }
let { [b]: b1, a: a1 } = c; // b1 is any; a1 is number

so should this be restricted then?

const b = "b";
let rest = { a: 10 };
let c = { [b]: b, ...rest };
let { [b]: b1, ...rest1 } = c; // BTW, this is allowed by the spec and Babel supports it

and generic case:

function destructure<T, U extends keyof T>(src: T, name: U): { value: T[U]; rest: rest(T, U); } {
  const { [name]: value, ...rest } = src;
  return { value, rest };
}

just thinking out loud

@sandersn
Copy link
Member Author

@rozzzly Yes.

@tinganho I would much prefer a general solution too; I tried to make something work with mapped types already. Maybe you can use these problems that I ran into to come up with something:

  1. There are too many quirks in rest's semantics to use it as a real basis for generalisation. (Why drop methods and call signatures? Because the definition is based on Object.assign, of course!) But the general solution still has to work for rest.
  2. Set difference of literal unions is the obvious general candidate, but doesn't really solve any compelling problems besides rest. At least I couldn't think of any.

@aluanhaddad
Copy link
Contributor

Although I don't love the proposed rest syntax I don't really care for the generic type syntax either in this case. The advantage of not introducing new syntax is just that it would allow a better syntax to be introduced, preferably one that covered more type operators, in the future without causing confusion because it would be a shorthand or sugar as opposed to an alternate manifest form.

@tinganho tinganho mentioned this pull request Jan 15, 2017
@tinganho
Copy link
Contributor

@sandersn So I submitted my idea to the issue tracker, though it is very rough. Though my initial idea was to solve typings for ORMs, but it seems it can apply to Rest<T, 'x', 'y'> as well. Please checkout #13500.

type Rest<T, ...S extends string[]> => {
    type Type;
    for (KT in T) {
        type IsInS = false;
        for (KS of S) {
            if (S[KT] != undefined) {
                IsInS = true;
            }
        }
        if (!IsInS) {
            Type[KT] = T[KT]
        }
    }
    return Type;
}

@sandersn
Copy link
Member Author

@Igorbek

  1. Even with a call to destructure, the compiler can't know what the value of the thing is, just the type, which is just some set of string literal types:
function concrete(tricky: keyof (typeof c)) {
  return destructure(c, tricky)
}

This might remove a and it might remove b from c: { a: number, b: string }. Should concrete return {}? There is no useful way to avoid hard-coding the field names in a rest type.

  1. The spec, babel and the typescript emitter all support computed properties with rest — check your examples in the playground to see that it handles computed properties correctly.

The typescript checker should be compared to flow, which doesn't support the generic case either and defaults to returning any (or probably an object with a string indexer, because flow doesn't produce any as readily as typescript). Even in the non-generic case, flow and typescript both fail to remove the computed property from the rest type if it's not a simple constant.

I will add an error instead. I don't like silently failing to remove properties that people asked to remove.

@aluanhaddad I hoped that introducing new, bad syntax would have nearly the same benefits as not introducing new syntax at all, with the added benefit that the syntax for generic types stays consistent.

@PyroVortex
Copy link

So, does the rest operator finally allow the strict typing of applying map to a homogenous tuple type of arbitrary length without a large number of overloads?

declare function map<A extends Array<T>, T, U>(input: A | [void], operator: (t: T) => U): Array<U> & { [P in keyof rest(A, keyof Array<T>)]: U };

const x = map([1,2,3], (x: number) => x.toString()); // x should have type [string, string, string], or at least one that has all the same semantics

Alternatively, if you want to generate invariant tuples:

function asInvariantTuple<A extends Array<any>>(input: A | [void]): rest(A, keyof []) & { readonly length: A['length'] } {
    return input;
}

const y = asInvariantTuple([1, 2, 3]); // y has type { 0: number, 1: number, 2: number, length: number }

It is an error with --noImplicitAny, unless the source type is also any.
@sandersn
Copy link
Member Author

You are right that I claimed that the only useful way to produce a rest type is from an object rest destructuring. The reason is that the ES spec defines both object rest and object spread as removing call signatures and non-own, non-enumerable properties, which Typescript approximates as class methods.

That means that basic identities just break down:

  1. {} ... T is not assignable to T.
  2. rest(T, never) is not assignable to T.

I considered adding a non-ES-based type similar to the intersection type called the difference type and then defining rest in terms of difference. But in January I arrived at the conclusion that it required too many additional features and that I couldn't find examples of any additional problems that difference solves on its own. In particular, I'm waiting to see existing Javascript code that can't be typed in Typescript right now.

@Igorbek
Copy link
Contributor

Igorbek commented Feb 21, 2017

Ah, yes, you're right. This rules are breaking basic identities.
We can still have some type relations in this form:

  • rest(T, keyof U) ... U is assignable to {} ... T (at least we removed redundant U type)

As an example of generic HOC could be:

const withProps =
  <P>(props: P) =>
    <T /* missing constraint that P extends T */>(Component: Component<T>) =>
      (innerProps: rest(T, keyof P)) =>
        <Component {...{innerProps, ...props}} />

const Sample = (props: { a: string; b: string; }) => <div>{a + b}</div>;
<Sample a='hello, ' b='world' />

const HelloSample = withProps({ a: 'hello, '})(Sample);
<HelloSample b='world' />

An alternative approach could be using something like Mapped conditional types #12424 where properties could be filtered out or replaced with some other type (like, make some members optional).

@Igorbek
Copy link
Contributor

Igorbek commented Feb 21, 2017

And one more alternative:

const withProps =
  <P>(props: P) =>
    <T>(Component: Component<{...T, ...P}>) =>
      (innerProps: T) =>
        <Component {...{innerProps, ...props}} />

This is perfect typing, but the issue here is that type T is not inferred (I'd like it would).

@raveclassic
Copy link

raveclassic commented Feb 21, 2017

@sandersn Maybe I should have not included | undefined in my example. This expression allows passing undefined values but forces to pass at least something (without ?:) - this is useful in a shared library with strict component api. It's a bit confusing, so let's omit it.
TStatefulProps cannot inherit TControlledProps because then ComponentClass<TStatefulProps> would require passing value each time to Stateful components while they hold this value inside their state and should not accept any.
So generally a HOC injects some required props to its child and also makes it possible to render this wrapped child without specifying these props like it's shown in @Igorbek's example with Sample and HelloSample.

@raveclassic
Copy link

Is there any way of seeing this merged at least without support of generics?

@panuhorsmalahti
Copy link

How about using \ (inspired by the complement of a set) for the syntax? e.g.:

function removeX<T extends { x: number, y: number }>(o: T): T \ 'x' {

@KiaraGrouwstra
Copy link
Contributor

KiaraGrouwstra commented May 29, 2017

Perhaps this could be solved with a syntax closer matching JS (avoids syntax clash, keeps learning curve low) with the type-level destructuring explored in #10727: type { a, ...Rest } = { a: 1, b: 2 };.

Maybe then one might grab them from objects and/or unions using a spread as well, hopefully allowing for straight-forward support for generics:

// destructure object
type Obj = { a: 1, c: 3 };
type { ...Obj, ...Rest } = { a: 1, b: 2 };
// Rest: { b: 2 }

Note Obj's role becomes more pattern-matching (leaving the rest for that ...Rest) than assignment.
Questions on most desirable behavior:

  • error if the values for keys in Obj don't match?
  • ignore unused keys in Obj? or error?
  • variance?

Alt.: destructure unioned keys

type Obj = { a: 1, c: 3 };
type Keys = keyof Obj; // 'a' | 'c'
type { ...Keys, ...Rest } = { a: 1, b: 2 };
// Rest: { b: 2 }

Notes:

  • destructuring unions here is new -- JS never had unions
  • Keys is a union of keys (pattern-matched to pass on whatever remains), while Rest is assigned an object here

@sandersn:

I arrived at the conclusion that [set difference of literal unions] required too many additional features

Not just ~ huh? I could theoretically foresee small use-cases e.g. number & ~0 (useful for typing a safe division operation?), but atm can't think of much outside of calculating object difference either.

@KiaraGrouwstra
Copy link
Contributor

KiaraGrouwstra commented Jun 2, 2017

Tried for a bit more to do a union difference operation using today's syntax. Unlike the current approach, this would handle the generics use-case as well.

// define some helpers...
type Obj<T> = { [k: string]: T };
type SafeObj<O extends { [k: string]: any }, Name extends string, Param extends string> = O & Obj<{[K in Name]: Param }>;
type SwitchObj<Param extends string, Name extends string, O extends Obj<any>> = SafeObj<O, Name, Param>[Param];
type Not<T extends string> = SwitchObj<T, 'InvalidNotParam', {
  '1': '0';
  '0': '1';
}>;
type Union2Obj<Keys extends string> = { [K in Keys]: K };
type Union2Keys<T extends string> = Union2Obj<T> & { [k: string]: undefined };
type UnionHas<Union extends string, K extends string> = ({[S in Union]: '1' } & { [k: string]: '0' })[K];

// okay, let's try this...
type UnionDiff<Big extends string, Small extends string> =
  {[K in Big]: { 1: Union2Keys<Big>[K], 0: undefined }[Not<UnionHas<Small, K>>]}[Big] /*!*/;
type test1 = UnionDiff<'a' | 'b' | 'c', 'b' | 'c' | 'd'>;
// 'a'|'b'|'c' instead of 'a' :(

This yields "a" | "b" | "c" instead of just "a". Somehow having the [Big] index operation directly in there fails -- help appreciated!

Second attempt, separate the steps...

// hm, let's separate the steps...
type UnionDiff2<Big extends string, Small extends string> =
  {[K in Big]: { 1: Union2Keys<Big>[K], 0: undefined }[Not<UnionHas<Small, K>>]} //[Big] /*!*/;
type A = UnionDiff2<'a' | 'b' | 'c', 'b' | 'c' | 'd'>;
// now manually do that second step...
type Big = 'a' | 'b' | 'c';
type B = A[Big];
// "a" (yay!), or `"a" | undefined` if `strictNullChecks` are enabled.

This returns either "a" (yay!), or "a" | undefined if strictNullChecks are enabled.

The strictNullChecks scenario could be fixed if the assertion operator ! were exposed on the type level... if my first attempt could be made to work as well, could that perhaps be an elegant route to tackle this feature?

Edit: included needed helpers.

@jaen
Copy link

jaen commented Jun 9, 2017

How about trying never instead of undefined? It seems to be working for me in strict mode:

type UnionDiff2<Big extends string, Small extends string> =
  {[K in Big]: { 1: Union2Keys<Big>[K], 0: never }[Not<UnionHas<Small, K>>]} //[Big] /*!*/;
type A = UnionDiff2<'a' | 'b' | 'c', 'b' | 'c' | 'd'>;
// now manually do that second step...
type Big = 'a' | 'b' | 'c';
type B = A[Big];

let testYes: B = "a"; // typechecks
let testNo: B = "b";  // fails

PS nice type level magic. Kind of sad this works only for string literal types, not arbitrary types due to index signature limitation though.

@KiaraGrouwstra
Copy link
Contributor

@jaen: progress! thanks for that! 😄

I'll concede the limitation is unfortunate, yeah. Out of curiosity though, what's your use-case?
They probably closed #4183 under the impression this covered most, as seen in @sandersn's quote:

I couldn't find examples of any additional problems that difference solves on its own

I couldn't think of much either, hence I'm all the more curious. :)

@jaen
Copy link

jaen commented Jun 10, 2017

Depends on use case of what you're asking about. If we're talking about subtracting of arbitrary types from an union (which is what led me here) it's fairly simple, our codebase uses tsmonad with strict null checks leading to some annoying issues, for example:

const maybe: <T>(value: T) => Maybe<T> =
    (value) =>
        !!value
            ? Just<T>(value)
            : Nothing<T>();

maybe(possiblyNull)
    .fmap(x => x + 2); // complains about `x` being possibly null, even though
                       // `fmap` will by definition be only called with inhabited
                       // `Just` values
    .valueOr(10)

which could be alleviated with something like either of (or preferably both):

const betterMaybe: <T>(value: T) => Maybe<T - null | undefined> = ;
// alternatively by lifting non-null assertion to type level
const betterMaybe: <T>(value: T) => Maybe<T!> = ;

Is that a satisfactory answer?

@KiaraGrouwstra
Copy link
Contributor

KiaraGrouwstra commented Jun 11, 2017

@jaen: hmm. what if you'd write say .fmap(x => x! + 2)? type-level ! would be nicer, but yeah...

Edit: oh, I know this isn't exposed on the type level either, but have you tried doing something like in the type guards and type assertions example? It looks like if you use a type guard that way that may suffice to have TS recognize the Just will no longer have null / undefined...

@jaen
Copy link

jaen commented Jun 11, 2017

Yeah, I am aware of the non-null assertion, but this has the overhead of having to write it out inside each fmap` lambda, which is suboptimal. It would be nice to solve this in one place with a proper type.

As for your suggestion — this will work I assume, but will require me forking tsmonad and modifying the source, which I wanted to avoid. Being able to do this on the type level has the added benefit on being able to do it in a local typings file to shadow the library's type.

So that's my use case, I guess — this can be done otherwise, by writing down a non-null assertion at each use site or forking or modifying the library to work with strict null checks, but both feel fairly inelegant to me.

@KiaraGrouwstra
Copy link
Contributor

@jaen:
Hmm. In this particular case, if I were you I'd just make a PR to tsmonad, since this seems like a fairly beneficial change, wouldn't suspect it to be particularly controversial.

From the TS team's perspective, I suppose a general solution was considered fairly involved, in the sense it'd require introducing negation types ('a type that is not T'), for one.
That said, the logic that would solve your current use-case appears to be implemented already in that ! operator -- in my perception, the only change needed to address this would be for them to just expose it as part of the type-level syntax as well.
Given demand for that, perhaps they'd be more open to that idea than to having to tackle the more complex generic case.

@jaen
Copy link

jaen commented Jun 11, 2017

Hm, I guess I could consider a PR. I thought the library was dead (had improvements over last version in the repo, but no officially released package), but now that I checked it's not, so it might make sense to change that upstream, I guess.

Well, I'd probably prefer a more generic solution, since while lifting the ! into types solves this use case, I can imagine other kinds of subtractions might come in hand in the future, but even having a type-level ! would be nice.

Do you have any link explaining why the negation type would be needed? I would assume you can remove from a union by equality (which types already have) so I'm not sure why some not-T would be required to implement type union subtraction. Maybe I'm missing some use cases though.

@KiaraGrouwstra
Copy link
Contributor

@jaen: that was this idea. There may well be ways without it. I personally just hope - will still be introduced for type-level numeric substraction hahaha, perhaps that just lead me to adhering that version rather than that in the OP there.

@KiaraGrouwstra
Copy link
Contributor

Putting things together based on @nirendy's solution to work around the glitch by cutting things into steps with generics defaults:

type Obj<T> = { [k: string]: T };
type SafeObj<O extends { [k: string]: any }, Name extends string, Param extends string> = O & Obj<{[K in Name]: Param }>;
type SwitchObj<Param extends string, Name extends string, O extends Obj<any>> = SafeObj<O, Name, Param>[Param];
type Not<T extends string> = SwitchObj<T, 'InvalidNotParam', {
  '1': '0';
  '0': '1';
}>;
type Union2Obj<Keys extends string> = { [K in Keys]: K };
type Union2Keys<T extends string> = Union2Obj<T> & { [k: string]: undefined };
type UnionHasKey<Union extends string, K extends string> = ({[S in Union]: '1' } & { [k: string]: '0' })[K];

export type UnionDiff_<Big extends string, Small extends string> =
  {[K in Big]: { 1: Union2Keys<Big>[K], 0: never }[Not<UnionHasKey<Small, K>>]}//[Big];
export type UnionDiff<
    Big extends string,
    Small extends string,
    Step extends UnionDiff_<Big, Small> = UnionDiff_<Big, Small>
> = Step[Big];
type TestUnionDiff = UnionDiff<'a' | 'b' | 'c', 'b' | 'c' | 'd'>;
// ^ 'a'

@qm3ster
Copy link

qm3ster commented Jun 12, 2018

Should this be closed?

@RyanCavanaugh
Copy link
Member

On hold for now

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

Successfully merging this pull request may close these issues.