0
votes

I have the following State shape and want to define a flattened slice const that has the types of the state properties without needing to explicitly define/reference them again, so I would like a mapped type like MapFlatSlice:

type State = {
  fruit: {
    mango: 'haden' | 'keitt';
    papaya: 'sunrise' | 'strawberry';
  };
  colors: {
    green: 10 | 20 | 30;
  };
  season: {
    nartana: 'april' | 'may' | 'june' | 'july' | 'august';
  };
};

type MapFlatSlice = {
  // ...
};

const slice = {
  fruit: ['mango', 'papaya'],
  colors: ['green'],
  season: ['nartana'],
} as const;

type S = MapFlatSlice<typeof slice>;

Where S should be:

type S = {
  mango: 'haden' | 'keitt';
  papaya: 'sunrise' | 'strawberry';
  green: 10 | 20 | 30;
  nartana: 'april' | 'may' | 'june' | 'july' | 'august';
};

In the above example, slice is used for something like redux's mapStateToProps, like this:

const makeSlice = (s: Slice): (t: MapFlatSlice<typeof s>) => void => {
  // ...
};

So given:

const s1 = makeSlice(slice);

Then s1 would be:

const s1 = (props: {
  mango: "haden" | "keitt";
  papaya: "sunrise" | "strawberry";
  green: 10 | 20 | 30;
  nartana: "april" | "may" | "june" | "july" | "august";
}) => {};

(where the type of props in s1 is the same as S from above)


So I think that MapFlatSlice should be something like...

type MapFlatSlice<S extends { [k in keyof State]?: readonly (keyof State[k])[] }> = {
  [k in keyof S]: {
    [_k in S[k][number]]: ...
  };
};

But I don't know how to "flatten" it, and also it doesn't work to index S[k] with number.

2
I'm a bit confused. Are you asking to extract the types from State like this playground, or some object literal const extract, or was that just something you were attempting along the way?chrisbajorin
@chrisbajorin Thanks for your help. In the question I renamed "Extract" instances to "Slice" since that is already a utility type. As for your question, the const slice is passed to a function that returns a function that accepts the return type of MapFlatSlice<typeof slice>. It's similar to redux mapStateToProps. I have added more details to the question to hpefully clarify what I am trying to do.Mahesh Sundaram
Is slice meant to be selecting a subset of the existing properties? That is, is const slice = {fruit: ['mango'], color: ['green'], season: [] } as const; meant as a potential/intended input to the type?chrisbajorin
Yes that's what I'm hoping to accomplish, const slice = ... is meant as an input to the typeMahesh Sundaram
Uh, is State supposed to have colors or color as a key? Is this a typo? If so, could you fix it? If not, could you explain what's going on with the discrepancy?jcalz

2 Answers

1
votes

I'd be inclined to approach this as follows:


Which types are "sliceable"? That is, when you write MapFlatSlice<T>, what are the allowable types for T? I think it's any type T extends Sliceable<T> where Sliceable<T> is defined as:

type Sliceable<T> =
  { [K in keyof T]: K extends keyof State ? ReadonlyArray<keyof State[K]> : never }

That is, if T is sliceable, each property at key K must be a (possibly read-only) array of keys of State[K]. If K is not a key of State, then there should be no property at key K (so the property is of type never).


Now that we've got a constraint on the input to MapFlatSlice, what should the output be? I find this easiest to break into two steps. We will take the input type T and "invert" the keys and values, to give us something closer to the shape of what we want which still has enough information in it to get the job done.

Here it is:

type InvertKeyValues<T extends Record<keyof T, readonly PropertyKey[]>> =
  { [K in keyof T as T[K][number]]: K };

If we apply that directly to typeof slice, you'll see what we get:

type Intermediate = InvertKeyValues<typeof slice>;    
/* type Intermediate = {
    readonly mango: "fruit";
    readonly papaya: "fruit";
    readonly green: "color";
    readonly nartana: "season";
} */

The keys are the ones you want in your final output, and the values are the relevant keys from State we need to consult.


Given such an intermediate type as a new input U, we can do the final transformation:

type DoSlice<U> =
  { -readonly [K in keyof U]: Idx<Idx<State, U[K]>, K> }
    

Recall that U[K] is the key from State, while K is the subkey. So we want to write State[U[K]][K]. But the compiler can't tell that such index accesses are valid, so we use Idx<O, P> in place of O[P], which we define now:


Let's say we have an object type O and a key type P, but the compiler doesn't know that P is a key of O even though we think it is. That means we can't directly write the indexed access type O[P] without error. Instead, we can define an Idx type alias which checks P before indexing:

type Idx<O, P> = P extends keyof O ? O[P] : never;

Now, anywhere O[P] yields an error, we can replace it with Idx<O, P>. And so inside DoSlice, State[U[K]][K] becomes Idx<Idx<State, U[K]>, K>.


And finally, MapFlatSlice<T> is defined in terms of the constraint and the output operation:

type MapFlatSlice<T extends Sliceable<T>> =
  DoSlice<InvertKeyValues<T>>

So, does it work?

type S = MapFlatSlice<typeof slice>
/* type S = {
    mango: "haden" | "keitt";
    papaya: "sunrise" | "strawberry";
    green: 10 | 20 | 30;
    nartana: "april" | "may" | "june" | "july" | "august";
} */

Looks good. You can verify that other mappings should also work, and that adding incorrect properties to slice should result in compiler warnings.

Playground link to code

1
votes

Using a utility type ArrayType to extract the keys from your const slice arrays:

type ArrayType<T extends readonly any[] | undefined> = 
  T extends readonly (infer V)[] ? V : never; 

You can extract all the nested objects into a union using:

// Extract a union of the nested object properties
// State -> { mango: ...; papaya: ...; } | { green: ...; } | { nartana: ...; }
type GetNestedProps<State, Slice extends {[K in keyof State]?: readonly (keyof State[K])[]}> = {
    [K in keyof Slice]: K extends keyof State 
                     // ^ This typeguard lets us use `K` to index `State` in `Pick`
                     // where we'll select only the keys passed in by the `slice` object
        ? Pick<State[K], (ArrayType<Slice[K]> extends keyof State[K] ? ArrayType<Slice[K]> : never)> 
        //                                    ^ this is a necessary typeguard to make sure we can use the
        //                                      return of ArrayType<Slice[K]> as an index on State[K]
        : never
}[keyof Slice];
// ^ indexing with the keyof, we get a union of the values on the object

Any time you want to index with a key that wasn't extracted from the object, you need to use a typeguard to assure the compiler that the key can be used as an index on your object. If you notice, I made the keys optional in the type parameter. This will allow you to exclude the keys in your const slice instead of having an empty array. If you prefer to force empty arrays, you can remove the questions marks from [K in keyof State]?.

Now that you have a union of the nested properties, you can use the utility type UnionToIntersection to convert that to your desired type:

// X | Y -> X & Y
type UnionToIntersection<T> = 
  (T extends any ? (x: T) => any : never) extends 
  (x: infer R) => any ? R : never;

Finally, your actual type, where I added State as a type parameter to make it reusable across states:

type MapFlatSlice<State,Slice extends {[K in keyof State]?: readonly (keyof State[K])[]}> = 
  UnionToIntersection<GetNestedProps<State,Slice>>;

useable as:

const slice = {
  fruit: ['mango','papaya'],
  color: ['green'],
  season: ['nartana'],
} as const;

type S = MapFlatSlice<State,typeof slice>;

const s: S = {
    mango: 'haden',
    papaya: 'sunrise',
    green: 10,
    nartana: 'april'
}

playground link