typescripttypescript-generics

Typescript: capture/infer type with constraint without actually extending/widening


I have a function, build, that builds a User with the passed in params. I'd like to type the function such that it knows which params are passed in and takes them into account in the return value.

My first thought was to capture the type of params and use that in the return type:

type User = { name: string; admin: boolean };

const build = <T extends User>(params: T): T => params

This works well, as I can now do:

build({ name: 'Jan', admin: true });
// return type is: { name: string, admin: true } as desired (notice admin: true, not boolean)

But, capturing the type with the <T extends User> constraint seems to have an unintended consequence of also widening the type. The following is now allowed without error:

build({ name: 'Jan', admin: true, foo: 'shouldnt be allowed' });
// return type is: { name: string, admin: true, foo: string }

If I go with a simpler approach where I don't capture the type and just return a User, I do get the desired/expected behavior where passing params that don't exist on User results in an error, but the function returns a User without the extra type information from the params:

const build = (params: User): User => params

// Type error as desired: Object literal may only specify known 
// properties, and 'foo' does not exist in type 'User'
build({ name: 'Jan', admin: true, foo: 'sdf' })

What I'd like to know is if there is a way of capturing the type of params so that I can use it in the return type but without widening the type.


Solution

  • Typescript does not implement exact types, but you can create custom restrictions on the argument types. For example (playground):

    type User = { name: string; admin: boolean };
    
    const build = <T extends User>(
        params: keyof T extends keyof User ? T : never
    ): T => params
    
    const user1 = build({ name: 'Jan', admin: true, });
    
    // ERROR
    const user2 = build({ name: 'Jan', admin: true, x: 1});
    

    With the same idea, a bit more verbose, but with nicer error handling (playground):

    const build = <T extends User>(
        params: keyof T extends keyof User ? T : User
    ): Pick<T, keyof User> => params