typescripttypesmapped-types

Why is this mapped type removing the `?` decorator? How can we achieve a similar result without removing it?


Problem

We are building a mapped type that excludes properties of type Function. Our approach has a problem: it also removes the optional (?) decorator from mapped properties.

Reproduction

Here is a simplified reproduction of that behavior. NoOpMap1 behaves as we want it to, and NoOpMap2 has the problematic behavior.

type NoOpMap1<T> = { // Good. This one does not remove the ?
    [K in keyof T]: T[K];
};

type Keys<T> = {
    [K in keyof T]: K;
}[keyof T];

type NoOpMap2<T> = { // Problem. This one removes the ?
    [K in Keys<T>]: T[K];
};

Demo

type SomeType = {
    foo?: string,
}

// type SomeTypeNoOpMap1 = { foo?: string; }
type SomeTypeNoOpMap1 = NoOpMap1<SomeType>;

// type SomeTypeNoOpMap2 = { foo: string; }
type SomeTypeNoOpMap2 = NoOpMap2<SomeType>;

NoOpMap1 behaves as expected. It keeps the ? decorater on the foo property. NoOpMap2 removes it.

Question

Why is NoOpMap2 removing the ? decorator? How can we achieve a similar result without removing it?

Actual Use Case

Here is the complete type that we are trying to build:

type DataPropertyNames<T> = {
  [K in keyof T]: T[K] extends Function ? never : K;
}[keyof T];

type DataPropertiesOnly<T> = {
  [K in DataPropertyNames<T>]
  : T[K] extends (string | number | boolean) ? T[K]
  : T[K] extends (infer A)[] ? DataPropertiesOnly<A>[]
  : DataPropertiesOnly<T[K]>;
};

As mentioned, the above type is responsible for removing properties of type Function without removing the ? decorator from remaining properties.


Solution

  • If you want to preserve the optional/readonly status of properties in a mapped type, you need to make sure that the compiler perceives the mapping as homomorphic. I know of two ways to do this.

    One is for the mapping to be of the form {[K in keyof T]: ...} where you are directly mapping over keyof T for some T, generic or concrete. You have to have something like in keyof appearing directly in the type, or it won't count.

    interface Foo {
        optional?: string;
        readonly viewonly: string;
    }
    
    type Homomorphic = { [K in keyof Foo]: 0 };
    // type Homomorphic = { 
    //   optional?: 0 | undefined; 
    //   readonly viewonly: 0; 
    // }
    
    type KeyOf<T> = keyof T
    type NonHomomorphic = { [K in KeyOf<Foo>]: 0 };
    // type NonHomomorphic = { 
    //   optional: 0; 
    //   viewonly: 0; 
    // }
    

    The other way to do it is to map over a generic type parameter K which has been constrained to keyof T for another generic type parameter T. So:

    type GenericConstraint<T, K extends keyof T> = { [P in K]: 0 };
    type ConstrainedHomomorphic = GenericConstraint<Foo, keyof Foo>;
    // type ConstrainedHomomorphic = { 
    //   optional?: 0 | undefined; 
    //   readonly viewonly: 0; 
    // }
    
    type OnlySomeKeysStillHomomorphic = GenericConstraint<Foo, "viewonly">;
    // type OnlySomeKeysStillHomomorphic = {
    //   readonly viewonly: 0;
    // }
    

    This latter method was specifically added to get partial mapped types like Pick<T, K> to be homomorphic. And it's this method you need to get your actual use case to work:

    // unchanged
    type DataPropertyNames<T> = {
        [K in keyof T]: T[K] extends Function ? never : K;
    }[keyof T];
    
    // quick abort if T is a function or primitive
    // otherwise pass to a homomorphic helper type 
    type DataPropertiesOnly<T> =
        T extends Function ? never :
        T extends object ? DPO<T, DataPropertyNames<T>> :
        T
    
    // homomorphic helper type
    type DPO<T, KT extends keyof T> = {
        [K in KT]
        : T[K] extends (string | number | boolean) ? T[K]
        : T[K] extends (infer A)[] ? DataPropertiesOnly<A>[]
        : DataPropertiesOnly<T[K]>;
    }
    

    I think that will act the way you want.