Search code examples
typescripttypesmapped-types

How to differentiate between a mapped type with Pick and the original type when all properties are optional


I have a picked type ColumnSetting where the original type Column has almost all properties as optional:

type ColumnSetting = Pick<Column, 'colId' | 'width' | 'sort'>;

type Column = {
  colId: string,
  width?: number,
  sort?: number
  something?: string
}

Column has several more properties than ColumnSetting. However wherever ColumnSetting is used as a type the TS engine accepts Column without complaints. How can I define ColumnSetting to make the TS engine not allow Column where ColumnSetting is expected?


Solution

  • Object types in TypeScript are open in the sense that they extra allow properties not mentioned in the declaration. Indeed this is what makes interfaces and object types extendible. Otherwise inferface Foo {a: string} and interface Bar extends Foo {b: string} would put us in the unfortunate circumstances that Bar extends Foo but Bar is not a valid Foo. But {a: string, b: string} is assignable to {a: string}.

    Sometimes people get confused into thinking that TypeScript types are closed or sealed or exact (as mentioned in microsoft/TypeScript#12936), but the closest thing TypeScript has are excess property checks on object literals, which is more like a linter warning and not a type safety check.

    That means Pick<T, K> is always a supertype of T, so Column will be assignable to Pick<Column, K> no matter what K is. All Pick does is say that something must have certain keys, not that it must lack the remainder. If you want to exclude other properties, you must do so explicitly. TypeScript doesn't have a direct way to say "this key must not exist". But you can say that a property is optional and of the impossible never type. Since you can never find a value of type never, the only way of satisfying {foo?: never} is by not having a foo property. (Well, or you can make it undefined, assuming you don't have the --exactOptionalPropertyTypes compiler option enabled.)

    So you don't want Pick... you want something like ExclusivePick:

    type ExclusivePick<T, K extends keyof T> = 
        { [P in K]: T[P] } & { [P in Exclude<keyof T, K>]?: never }
    

    That is the same as Pick<T, K> intersected with an object type that prohibits all the keys of T excluding those in K.

    Let's test it out:

    type ColumnSetting = ExclusivePick<Column, 'colId' | 'width' | 'sort'>;
    
    /* type ColumnSetting = {
        colId: string;
        width?: number | undefined;
        sort?: number | undefined;
    } & {
        something?: undefined;
    }*/
    

    Now if you try to assign a Column to that, you'll get an error:

    function foo(col: Column) {
        const columnSetting: ColumnSetting = col; // error!
        //  Types of property 'something' are incompatible.
        //  Type 'string' is not assignable to type 'undefined'   
    }
    

    The compiler is saying that the only value it would accept for the something property of a ColumnSetting would be undefined, not string, and so Column is inappropriate.

    Playground link to code