Execute Program

Advanced TypeScript: Generic Constraints and Type Compatibility

Welcome to the Generic Constraints and Type Compatibility lesson!

This lesson is shown as static text below. However, it's designed to be used interactively. Click the button below to start!

  • We've seen type compatibility at many points in this course. Now we'll look at it more closely, paying special attention to how it interacts with generic constraints.

  • As a simple example, we can assign a literal string type like 'name' to a string. That works because the 'name' type is a kind of string. The strings below live inside of an object, but the same compatibility rules apply.

  • >
    const aName: {key: 'name'} = {key: 'name'};
    const aString: {key: string} = aName;
    aString;
    Result:
    {key: 'name'}Pass Icon
  • We can also assign any union of literal string types to a string.

  • >
    const nameOrAge: {key: 'name' | 'age'} = {key: 'age'};
    const aString: {key: string} = nameOrAge;
    aString;
    Result:
    {key: 'age'}Pass Icon
  • We can assign a literal string type to a union that contains that string.

  • >
    const aName: {key: 'name'} = {key: 'name'};
    const nameOrAge: {key: 'name' | 'age'} = aName;
    nameOrAge;
    Result:
    {key: 'name'}Pass Icon
  • However, we can't assign a literal string type to a union that doesn't include that literal string type.

  • >
    const aName: {key: 'name'} = {key: 'name'};
    const emailOrAge: {key: 'email' | 'age'} = aName;
    emailOrAge;
    Result:
    type error: Type '{ key: "name"; }' is not assignable to type '{ key: "email" | "age"; }'.
      Types of property 'key' are incompatible.
        Type '"name"' is not assignable to type '"email" | "age"'.Pass Icon
  • We also can't assign a union to another incompatible union, even if they overlap partially. In the example below, we try to use a 'name' | 'age' as an 'email' | 'age'. If the actual runtime value is 'age', that's fine. But if it's 'name', then it's not fine! When type checking at compile time, TypeScript can't predict which value we'll use. Although the value of nameOrAge is {key: 'age'}, its type is still {key: 'name' | 'age'}

  • >
    const nameOrAge: {key: 'name' | 'age'} = {key: 'age'};
    const emailOrAge: {key: 'email' | 'age'} = nameOrAge;
    emailOrAge;
    Result:
    type error: Type '{ key: "name" | "age"; }' is not assignable to type '{ key: "age" | "email"; }'.
      Types of property 'key' are incompatible.
        Type '"name" | "age"' is not assignable to type '"age" | "email"'.
          Type '"name"' is not assignable to type '"age" | "email"'.Pass Icon
  • Now we'll explore how these type compatibility rules interact with generic constraints like <Row extends {age: number}>, which we saw in an earlier lesson.

  • In the next example, we define a SpecificKey<T> type. It gives us object types whose key property must be T. For example, SpecificKey<'name'> gives us the type {key: 'name'}.

  • >
    type SpecificKey<T extends string> = {key: T};

    const nameKey: SpecificKey<'name'> = {key: 'name'};
    nameKey.key;
    Result:
    'name'Pass Icon
  • >
    type SpecificKey<T extends string> = {key: T};

    const ageKey: SpecificKey<'name'> = {key: 'age'};
    ageKey.key;
    Result:
    type error: Type '"age"' is not assignable to type '"name"'.Pass Icon
  • There's a subtle but important point shown in those examples. We declared our type parameter as <T extends string>, but the actual type of T was 'name'.

  • The generic constraint <T extends string> doesn't mean that T will be exactly string. Instead, T must be some type that is compatible with string. That type could be the literal string 'name', or a union of literal strings like 'name' | 'age', or even the full string type. All of those are compatible with string, so we can use any of them as T.

  • The next examples show that our SpecificKey<T> type works with unions. It gives us object types like {key: 'name' | 'age'}.

  • >
    type SpecificKey<T extends string> = {key: T};

    const nameOrAge: SpecificKey<'name' | 'age'> = {key: 'age'};
    nameOrAge.key;
    Result:
    'age'Pass Icon
  • >
    type SpecificKey<T extends string> = {key: T};

    const nameOrAge: SpecificKey<'name' | 'age'> = {key: 'email'};
    nameOrAge.key;
    Result:
    type error: Type '"email"' is not assignable to type '"name" | "age"'.Pass Icon
  • Like before, we're not allowed to mix unions up, even if they overlap partially.

  • >
    type SpecificKey<T extends string> = {key: T};

    const nameOrAge: SpecificKey<'name' | 'age'> = {key: 'age'};
    const emailOrAge: SpecificKey<'email' | 'age'> = nameOrAge;
    emailOrAge;
    Result:
    type error: Type 'SpecificKey<"name" | "age">' is not assignable to type 'SpecificKey<"age" | "email">'.
      Type '"name" | "age"' is not assignable to type '"age" | "email"'.
        Type '"name"' is not assignable to type '"age" | "email"'.Pass Icon
  • We'll see some more realistic examples of these ideas in future lessons. For now, it's important to recognize that the usual type compatibility rules still apply in generic constraints. A constraint like <T extends string> allows any type that's compatible with string.