Search code examples
typescripttypescript-generics

Use type parameters with exact type instead of extending


I have a function that accepts a parameter that can have one of two different types. I want to type the return type based on that parameter.

I need to use a type parameter so I can reference the parameter type in the return type. But I don't want to use the extends keyword as that would allow putting any supertype into the function. Example:

type Cat = {
  name: string;
}

type CatWithOwner = {
  owner: string;
} & Cat;

const getNicknamedCat = <C extends Cat | CatWithOwner>(
  cat: C
): C extends CatWithOwner
  ? CatWithOwner & { nickname: string }
  : Cat & { nickname: string } => {
  return { ...cat, nickname: name + "y" };
};


// works
getNicknamedCat({ name: "Nick" })

// works, return type also has the owner
getNicknamedCat({ name: "Nick", owner: "Winston" })

// works, too, but I don't want this. I want to forbid non-Cat/CatWithOwner-keys
getNicknamedCat({ name: "Nick", unrelated: "foo" })

I need the type parameter so I can reference it in the return type but is there any way to "assign" the type parameter a type instead of extending?

What I want is something like this:

const getNicknamedCat = <C is Cat | CatWithOwner>(
  cat: C
): C extends CatWithOwner
  ? CatWithOwner & { nickname: string }
  : Cat & { nickname: string } => {
  return { ...cat, nickname: name + "y" };
};


// works
getNicknamedCat({ name: "Nick" })

// works, return type also has the owner
getNicknamedCat({ name: "Nick", owner: "Winston" })

// error, "unrelated" is not expected
getNicknamedCat({ name: "Nick", unrelated: "foo" })

Solution

  • Typescript uses structural type system, also known as "duck-typing", which checks the shape of the type, compared to statically typed languages like Java, where the identity is checked. This means that you can expect type X, but be able to send type Y with the same shape and some extra properties.

    Fortunately, there is a workaround. Not ideal, but at least it works.

    Logic: Since you want to check if the passed type is a member of a union(Cat or CatWithOwner), we will need to use distributive conditional types to handle each member of union separately. After that, we will remove all keys of the member union from the checked type. If it is going to be an empty object after that, it means that there are no extra fields. However, it doesn't work recursively, thus if you would have nested extra fields, they would pass the check. Though, this type can be easily adapted. If the type isn't an empty object, we can return never, which will fire an error or a bit of complex error messaging to let the user know what is exactly wrong. The latter one can be done by adding some symbol property that will contain the names of the extra fields:

    type ExactlyTheGivenType<T extends Base, Base> = Base extends Base
      ? {} extends Omit<T, keyof Base>
        ? T
        : Record<
            __internal__,
            `Following key is redundant: ${keyof Omit<T, keyof Base> & string}`
          >
      : never;
    

    Usage:

    const getNicknamedCat = <C extends Cat | CatWithOwner>(
      cat: ExactlyTheGivenType<C, Cat | CatWithOwner>,
    ): C extends CatWithOwner
      ? CatWithOwner & { nickname: string }
      : Cat & { nickname: string } => {
      return {} as any;
    };
    

    Testing:

    getNicknamedCat({ name: 'Nick' });
    
    getNicknamedCat({ name: 'Nick', owner: 'Winston' });
    
    // expected error
    // Argument of type '{ name: string; unrelated: string; }' is not assignable to parameter of type 'Record<symbol, "Following key is redundant: unrelated">'
    getNicknamedCat({ name: 'Nick', unrelated: 'foo' });
    
    getNicknamedCat({ name: 'Nick' });
    
    getNicknamedCat({ name: 'Nick', owner: 'Winston' });
    
    // expected error
    // Argument of type '{ name: string; unrelated: string; unrelated2: string; }' 
    // is not assignable to parameter of type
    // 'Record<symbol, "Following key is redundant: unrelated" | "Following key is redundant: unrelated2">'
    getNicknamedCat({ name: 'Nick', unrelated: 'foo', unrelated2: 'da' });
    
    

    Link to Playground