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

Preserve tuples when mapping with as clauses #40586

Open
5 tasks done
millsp opened this issue Sep 16, 2020 · 10 comments
Open
5 tasks done

Preserve tuples when mapping with as clauses #40586

millsp opened this issue Sep 16, 2020 · 10 comments
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@millsp
Copy link
Contributor

millsp commented Sep 16, 2020

Search Terms

#tuple #mapped #as #clause #preserve

Suggestion

New mapped type's as clause allows one to filter the entries of an object. Basic mapped types preserve the shape of a tuple but not when used with the new as clause:

type tuple = [0, 1, 2]

type Mapped<T> = {
    [K in keyof T]: T[K] | undefined
}

type MappedAs<T> = {
    [K in keyof T as K]: T[K] | undefined
}
type test0 = Mapped<tuple>   // tuple shape is preserved
type test1 = MappedAs<tuple> // tuple shape is object now
test0 === [0 | undefined, 1 | undefined, 2 | undefined]
test1 === {
    [x: number]: 0 | 1 | 2 | undefined;
    0: 0 | undefined;
    1: 1 | undefined;
    2: 2 | undefined;
    length: 3 | undefined;
    toString: (() => string) | undefined;
    toLocaleString: (() => string) | undefined;
    ...
}

Use Cases

  • Filtering lists without having to rely on costly recursive types

Examples

type FilterNumbers<T> = {
    [K in keyof T as T[K] extends number ? never : K]: T[K] | undefined
}

type test2 = FilterNumbers<[1, '2', 3, '4']> // ['2', '4']

Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.
@DanielRosenwasser DanielRosenwasser added In Discussion Not yet reached consensus Suggestion An idea for TypeScript labels Sep 16, 2020
@DanielRosenwasser
Copy link
Member

I believe this was a conscious design decision, and in this case I think compacting a tuple to a shorter length is pretty surprising to me. I would have expected a sparse array type in this case.

@millsp
Copy link
Contributor Author

millsp commented Sep 16, 2020

My use-case was for handling _ placeholders on ramda's curry. It allows one to pass a parameter's argument later if _ was passed as an argument. If _ is found then we keep its parameter's type for later, otherwise it gets consumed (filtered out). So this is to update the parameters after consumption, not sure if I can do this with a sparse array?

@millsp
Copy link
Contributor Author

millsp commented Sep 16, 2020

We could also have Pick and Omit for tuples thanks to this. An implementation of Pick and Omit that works for both objects and tuples.

@millsp
Copy link
Contributor Author

millsp commented Sep 17, 2020

This would be awesome if it could also preserve tuple labels after filtering. I got a version of curry that preserves tuple labels (from the curried function's params) by splitting up the individual parameters and then re-joining them after sorting - it's painful.

@jcalz
Copy link
Contributor

jcalz commented Sep 24, 2020

I kind of expect tuples/arrays to stay tuples/arrays if the as clause only produces numeric or number-like keys (I mean, K in keyof T as `get${K}` cannot reasonably stay a tuple/array, right?). If a numeric index in the middle is missing (so "0","1","3"), I expect a sparse tuple, not a shortened one. There's no way I can parse K in keyof T as Exclude<K, "2"> to somehow turn "3" into "2" without taking crazy pills first.

Now maybe if we suppress indices at the end it can produce a shortened tuple, since the as clause maps numeric keys in a straightforward way. So I'd be on board with preserving arrays/tuples in all cases where the resultant keys are numericlike, and making the output tuple have a length one larger than the largest mapped numeric key in the output type. Like this:

type KillTwo<T> = { [K in keyof T as Exclude<K, "2">]: T[K] };
type A = KillTwo<["a", "b", "c", "d"]>; // ["a", "b", undefined, "d"] or ["a", "b", never, "d"] or ??
type B = KillTwo<["a", "b", "c"]>; // ["a", "b"]

@millsp
Copy link
Contributor Author

millsp commented Sep 25, 2020

@jcalz Why not allow to delete entries, to create a real filter? That's how the current feature works with objects. On top of that, if we want the behavior you describe KillTwo could be written with:

type KillTwo<T> = { [K in keyof T]: K extends "2" ? never : T[K] };

This is the reason why I'm asking for as to serve as a filter, just like it does for objects. I want filtering capabilities because recursive types are heavy and don't preserve labels. On top of that, we could write Pick & Omit that work both on lists and objects - what is the point of getting an ugly object type if you Pick fields in a tuple?

  • If all the keys are numeric, then the base (tuple/array) shape is preserved.

  • If the tuple is somehow enlarged (sparse), then typescript should make an array out of it.

  • If the tuple is shortened, the concerned keys are removed and the indexes recalculated.

  • Filtering keys would work on tuples, but do nothing on arrays:

type KillTwo<T> = { [K in keyof T as Exclude<K, "2">]: T[K] };
type test0 = KillTwo<[1, 2, 3]> // [1, 2]
type test1 = KillTwo<(1 | 2 | 3)[]> // (1 | 2 | 3)[]

Arrays don't have specific keys, Exclude<K, "2"> will always result in number for arrays

  • Filtering "values" would work on tuples and on arrays:
type Filter2<T> = { [K in keyof T as T[K] extends 2 ? never : K]: T[K] };
type test0 = Filter2<[1, 2, 3]> // [1, 3]
type test1 = Filter2<(1 | 2 | 3)[]> // (1 | 3)[]

Both array and tuple will match the condition T[K] extends 2, making filtering possible.

@jcalz
Copy link
Contributor

jcalz commented Sep 25, 2020

That's how the current feature works with objects

Does it, though? I think you and I must have different ideas on what is considered a filter. Here's an object:

type MyOmit<T, K extends PropertyKey> = { [P in keyof T as Exclude<P, K>]: T[P] };

type Hmm = MyOmit<{ zero: "a", one: "b", two: "c", three: "d" }, "two"> // { zero: "a"; one: "b"; three: "d"; }

I would be exceptionally surprised if what came out were { zero: "a"; one: "b"; two: "d" } and I'm guessing you would be also. But in my view this is exactly what you're asking for from this:

type AlsoHmm = MyOmit<["a", "b", "c", "d"], "2"> // ???

  • If the tuple is shortened, the concerned keys are removed and the indexes recalculated.

I would love to be able to manipulate tuples at the type level, but on what grounds would you argue that as clauses should result in recalculating indices, as opposed to some other mechanism that would do this? I understand that it would be useful, but I don't see how to justify it.

@millsp
Copy link
Contributor Author

millsp commented Sep 25, 2020

but on what grounds would you argue that as clauses should result in recalculating indices, as opposed to some other mechanism that would do this

I justified (to myself) that as was a good candidate for this feature because:

  • I want to see a standardization of the type system. A single way to deal with arrays/tuples/objects. One mapped type to rule them all.

  • I'm not opposed to having another mechanism at all, but I aim for simplicity. Now that we have as, I felt that because mapped types already preserve tuple/array shapes, why not mapped types with as clauses?

  • I don't see a case where we would like to see a tuple/array become an object. Most of the time, we would like to preserve its original shape.

  • In fact, I'm asking for better mapped types for tuples/array, and a standard way to write mapped types that work for all data types.

@jcalz
Copy link
Contributor

jcalz commented Sep 26, 2020

I find myself mostly agreeing with all those points, so maybe we're talking past each other? I'm going to try one more time and then stop:

I am in favor of as clauses mapping tuples to tuples and arrays to arrays, as long as numeric-like indices map to numeric-like indices in the clause. If that's all you were asking for I'd be 👍👍👍. I don't want to see a tuple become an object either.

But deleting numeric keys from an array does not logically result in a shorter filtered array; it results in a sparse array of the same length:

// as a mapped type
type FilterNumbers<T> = {
    [K in keyof T as T[K] extends number ? never : K]: T[K];
}

// as a mapped array/object
function filterNumbers(t: { [k: string]: any }) {
    const ret: { [k: string]: any } = Array.isArray(t) ? [] : {};
    for (let k in t) (typeof t[k] === "number") ? (delete ret[k]) : (ret[k] = t[k]);
    return ret;
}

console.log(filterNumbers({ one: "a", two: 2, three: "b", four: 3, five: "c" }));
// {one: "a", three: "b", five: "c"}

console.log(filterNumbers(["a", 2, "b", 3, "c"]));
// ["a",  , "b",  ,"c"]

The automatic reindexing you're talking about which turns a sparse array into a shorter array sounds like a great thing to have in the type system, but I just don't see how mapped types with as clauses should result in that.

It certainly can't be done by a straightforward reading of K in keyof T as F<K> where F<K> is always either K or never. That K is a real index from the keys of T. If it gets mapped to never, then that key is not present in the output type. If it gets mapped to K then that key is present in the output type. That's how it works with objects, and that's what I'd expect it to do with tuples also. I don't have much use for sparse tuples, but that's what I'd expect to see here.

It really feels like Anders gave us a screwdriver and we are now trying to use it to drive nails into a wall. But for that I want a hammer, not a screwdriver which auto-detects nails and sprouts a hammer head. Honestly, though, if such a screwdriver-with-optional-hammer-abilities were given to us, I'm sure I'd use it too. And maybe even be happy about it. But I'd sure feel awkward when explaining it to people on Stack Overflow.

Anyway, good luck with this feature request!

@insidewhy
Copy link

I'm reading sparse array a lot here, I guess sparse tuple is what is intended. I assume that would be a tuple with never types in one or more positions. Producing a sparse tuple would be cool if there's a way to compact it. You'd probably see it being compacted the vast majority of the time when filtering keys. The verbosity is okay if it makes things clearer. Without being able to compact a sparse tuple, producing such a tuple would not be useful for most of our use cases (mine is to produce a parser combinator library).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

4 participants