Intermediate Typescript with React

Intermediate Typescript with React

These are the concepts I learned while doing the frontend masters' typescript with react course which helped me understand Typescript better.

Hello once more! Today, we'll talk about the concepts I learned in Frontend Masters' React and Typescript course by Steve Kinney. I'm assuming you're familiar with certain intermediate react and typescript fundamentals, such as generics, function overload, and higher order functions.

1. Utility types

With typescript, adding types to meet our needs is incredibly easy. While in some cases, we may have to perform some transformations on existing types rather than adding a new type. TypeScript provides utility types that facilitate such type transformations. Utility types provide functions that are built-in to typescript and are accessible globally. They extensively use generics behind the scenes. TypeScript comes with a large number of utility types. The following is not an exhaustive list, but some of the essential types we can use in the react apps.

keyof

keyof allows to get all the keys of an given type in a union. Essentially, keyof will enable us to retrieve a list of all the names associated with a prop for a specific object.

type ObjectType = {
  first: 1;
  second: 2;
};

type Result = keyof ObjectType; // Inferred Type: "first" | "second"

using intersection & operator with keyof

image.png

Let's say we just want keys only of a specific type; we can use the & operator. Therefore, as you can see, we obtain all of the date's string-based property names.

image.png

typeof

The typeof type query allows you to extract a type from a value.

const person = {
  rollNo: 19,
  name: "Prerana",
};

let employee: typeof person;

The variable person doesn't have any type. The type of person is inferred by typescript from the initialization. Using the typeof query, we can construct a second variable employee with the same type as person.

image.png

We could be thinking, where can we use this? What if you're using a library that makes some information and values available to you? However, they don't explicitly provide you with these items' type information in the form of an interface or a type alias that you might independently import and export. What if this were a webpack configuration, a huge, extremely complex object with a lot of content on it? If you wanted to create a function that accepts a webpack configuration as an argument, we can use typeof there.

Get a single key type from an object

If you need to get a single key value, you can use a bracket notation, as we have seen in JavaScript. So firstName will get us the type of Prerana.

type Person = {
  firstName: "Prerana";
  lastName: "Nawar";
  age: 19;
};

type Result0 = Person["firstName"]; // Inferred Type: "Prerana"

type Result1 = Person[firstName | age]; // Inferred Type: "Prerana" | 19

type Result2 = Person["lastName" | "lastName"]; // Inferred Type: "Prerana" | "Nawar"

Get values from the type

If we want to get all the values, there isn't a necessarily built-in helper in Typescript. But, this is one of the similar way we can get values of object in javascript. Here as well, we'll get all the values from the Obj type.

type Obj = {
  a: "A";
  b: "B";
  c: number;
};

type Values = Obj[keyof Obj]; // // Inferred Type: number | "A" | "B"

Exclude

In order to build a new type, Exclude<Type, ExcludedUnion> removes all assignable union members from the type. It is helpful to exclude specific keys from an object. For example, if there are certain values that we want to avoid passing to a smaller component. It says, "I need everything from the Type except for one value."

type Exclude<T, U> = T extends U ? never : T;

// Inferred Type: 1 | 3
type Result0 = Exclude<1 | 2 | 3, 2>;

// Inferred Type: "a" | "b"
type Result1 = Exclude<1 | "a" | 2 | "b", number>;

// Inferred Type: "a" | 2
type Result2 = Exclude<1 | "a" | 2 | "b", 1 | "b" | "c">;

Extract

The inverse of Exclude. Extract<Type, Union> generates a new type by extracting all union members assignable to Union from Type. It is useful to find the common base of two types.

type Extract<T, U> = T extends U ? T : never;

// Inferred Type: 1 | 2
type Result1 = Extract<1 | "a" | 2 | "b", number>;

// Inferred Type: 1 | "b"
type Result2 = Extract<1 | "a" | 2 | "b", 1 | "b" | "c">;

To further comprehend, let's examine another case.

type ColorPalette =
  | "yellow"
  | "pink"
  | "white"
  | { red: number; green: number; blue: number };

// Inferred Type: { red: number; green: number; blue: number }
type ColorObj = Extract<ColorPalette, { red: number }>;

Give me any sub-part of the type ColorPalette that has the property type red, which is a number. Remember, we don't need to declare a precise match, just the minimum requirement. We don't need to specify this whole object { red: number; green: number; blue: number }.

How does Extract and Exclude exactly work?

Here are the definitions of these two types.

/**
 * Exclude from T those types that are assignable to U
 */
type Exclude<T, U> = T extends U ? never : T
/**
 * Extract from T those types that are assignable to U
 */
type Extract<T, U> = T extends U ? T : never

So, to put it simply, we have a type T, which is a type parameter, and it's what we're operating on. Then we have typed U, which describes what we were searching for. So think of this as looking at all the different parts of type T, especially if it's a union type with this entire operation here. Each piece will be examined, and we will determine which pieces extend from U.

Which of these satisfies U or is even more precise than U while still fulfilling basic criteria? If this turns out to be true in the exclude case, we leave it out. We leave out whatever fits; otherwise, we let it pass through. And this is the only difference between the two.

Extract is just the opposite situation. This is a good illustration of how you might use conditional types, which are explained below.

Pick

PickType, Keys> creates a new type by taking the given keys (a string literal or a union of string literals) from the specified type. Pick generates a new type by taking the supplied collection of properties keys from type and combining them. It is useful if you only need a subset of the properties that you are interested in.

type ObjectLiteralType = {
  john: 1;
  paul: 2;
  george: 3;
  ringo: 4;
};

// Inferred Type: { george: 2; ringo: 4; }
type Result = Pick<ObjectLiteralType, "george" | "ringo">;

Omit

Omit<Type, Keys> is opposite to "Pick". It takes all of this type's attributes, but skips the values mentioned in keys. **Omit **generates a new type by choosing all of Type's properties, and then removing keys. It is helpful when you need to exclude a portion of characteristics.

type Fruit= {
  name: string;
  type: string;
  isSweet: boolean;
  containsCitrus: boolean;
};

// Inferred Type: {  name: string; type: string; isSweet: boolean; }
type Mango = Omit<ObjectLiteralType, "containsCitrus">;

String Manipulation Utilities

We can convert the values in the following ways by using these string manipulation utilities :

type UppercaseString = Uppercase<"string">; // STRING
type LowercaseString = Lowercase<"String">; // string
type CapitalizeString = Capitalize<"string value">; // String value
type UncapitalizeString = Uncapitalize<"String Value">; // string Value

Conditionals

Conditional types are similar to the ternary operator in Javascript. Typescript will determine whether or not to assign a certain type to a particular type based on the criteria. Conditional types primarily work with generics. Consider this: It is an array if type T has the length attribute.

type Wrap<T> = T extends { length: number } ? T[] : T;

Unions With Objects

The intriguing aspect of why I bring this up is that although unions may be anything as long as it isn't either of them, it works somewhat in the reverse way with objects. So, if you unioned objectTypeA and objectTypeB, the sharedProp property would be the only thing included because it's the sole shared property between the two.

type ObjectTypeA = {
  firstProp: number;
  sharedProp: string;
};

type ObjectTypeB = {
  secondProp: boolean;
  sharedProp: string;
};

type Union = ObjectTypeA | ObjectTypeB;

Objects & index signatures

The following are some activities that you can perform using index signatures on objects.

// Inferred Type: { a: number; b: number; c: number; }
type Result = {
  [K in "a" | "b" | "c"]: number;
};

type Dropdown = {
  isActive:boolean
  isExpanded:boolean
}

type DefaultState = {
  [K in keyof Dropdown]: true;
};

image.png

image.png

2. Template Literals

TypeScript allows us to use template literals on types to create literal types with template strings. This feature came with typescript 4.2.

Consider creating a component library containing a box component that has width, height, and alignment attributes. In a perfect world, the alignment property would only be one of the Vertical and HorizontalAlignment combinations, such as top-left, bottom-left, etc. The alignment of the shouldBreak object is left-right, which will result in an run-time error. However, typescript won't raise an issue because we typed the alignment property as a "string". Let's try using template literals to fix this.

type VerticalAlignment = 'top' | 'center' | 'bottom';
type HorizonalAlignment = 'left' | 'center' | 'right';

type Box = {
  width: number;
  height: number;
  alignment: string;
};

const a: Box = {
  width: 10, height: 10,
  alignment: 'top-center',
};

const b: Box = {
  width: 20, height: 20,
  alignment: 'bottom-right',
};

const shouldBreak: Box = {
  width: 20, height: 20,
  alignment: 'left-right',
};

We can create an Alignment type, a template literal that contains the combination of VerticalAlignment and HorizonalAlignment type, and use it on the alignment property.

type Alignment = `${VerticalAlignment}-${HorizonalAlignment}`;

As you can see, typescript throws an error right away.

image.png

3. Limiting Props with Function Overloads

image.png

image.png

Suppose, we have a card which contains text and a button which expands the text if it is truncated. The Text component takes two props:

  • truncate: is used to truncate the text

  • expanded: a boolean state to expand and contract the text.

import { useState } from "react";

type TextProps = {
  children: string;
  truncate?: boolean;
  expanded?: boolean;
};

const exampleText =
  "When I was born, the name for what I was did not exist. They called me nymph, as........";

const truncateString = (string: string, length = 100) =>
  string.slice(0, length) + "…";

function Text({ children, truncate = false, expanded = false }: TextProps) {
  const shouldTruncate = truncate && !expanded;
  return (
    <div aria-expanded={!!expanded}>
      {shouldTruncate ? truncateString(children) : children}
    </div>
  );
}

const Application = () => {
  const [expanded, setExpanded] = useState(false);
  return (
    <main>
      <Text truncate expanded={expanded}>
        {exampleText}
      </Text>
      <section style={{ marginTop: "1em" }}>
        <button onClick={() => setExpanded(!expanded)}>
          {expanded ? "Contract" : "Expand"}
        </button>
      </section>
    </main>
  );
};

export default Application;

Ideally, we need to have the expanded prop only if the truncate prop is present.

truncated-text-incomplete - CodeSandbox.gif

Is there any way in typescript through which we can ensure that we can't pass in expanded unless truncate is also passed in?

Let's see!

We'll change the 'TypeProps' type. We'll create a type called NoTruncateTextProps that has everything in TextProps plus truncate as false. To be clear, we are not claiming that this is the boolean. We're stating that this is a version where it is set to false.

Then there will be another version called TruncateTextProps, which will be TextProps when truncate is true and expanded can be a boolean only when truncate is true. As a result, we don't have a circumstance in which truncate is false and expanded exists on the type. There is no scenario in which we may accept an expanded prop if truncate does not exist.

As a result, we combination of three:

  • The shared, common props

  • A variant in which we pass a truncate prop.

  • A variant in which we supply a truncate and an optional expanded prop.

We don't have a version of the type that has expanded without being truncate.

type TextProps = {
  children: string;
};

type NoTruncateTextProps = TextProps & { truncate?: false };
type TruncateTextProps = TextProps & { truncate: true; expanded?: boolean };

Prerequisite Note: Please keep in mind that you should be conversant with function overloads. If not, please read this blog.

Now, the Text component should either take NoTruncateTextProps or TruncateTextProps which we can acquire using function overloads.

function Text(props: NoTruncateTextProps): JSX.Element;
function Text(props: TruncateTextProps): JSX.Element;
function Text(props: TextProps & { truncate?: boolean; expanded?: boolean }) {
  // implementation code...
}

So, if we go ahead and delete truncate, it will be unhappy with us. It states that you cannot expand anything that has not been truncated. So, perhaps, we can now be more intentional about how we anticipate this component to be utilised to reduce edge situations.

image.png

4. Higher Order Components (HOC) with Typescript

Assume we want a list that needs to be presented across several places. We can have a higher-order component that can can display a loader while a component waits for data to be retrieved and delivered to its props. We can construct a generic HOC and type it that can track those props and indicate a loading status.

function withList(Component) {
  return (props) => {
    const [list, setlist] = useState(null);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
      fetchList().then((result) => {
        setlist(result);
        setLoading(false);
      });
    }, []);

    if (loading) return <Loading />;
    return list && <Component {...props} list={list} />;
  };
}

const Application = () => {
  const ListWithData = withList(List);

  return (
    <main>
      <ListWithData/>
    </main>
  );
};


export default Application;

This HOC function is currently untyped. Now let's type it!

In this situation, the component we're covering takes a list. We want to indicate that list will accept any component that accepts a list parameter. Then, without the list component, we want to return a component that accepts all of the properties it originally took.

We'll utilise generics because the HOC will accept any component. The component should be a T-based React component type.

We'll return a new component with the same properties. So we're reading the props of whatever component we provide in and producing a new component that accepts the same props for the time being.

function withList<T>(
  Component: React.ComponentType<T>
) {
  return (props: T) => {
   // implementation code
  };
}

We need to return a component that takes all of the properties it originally accepted, but excludes the ones we know we'll pass in.

type WithListProps = {
  list: ListModel;
};

What are the props defined by the higher order component? It's merely a list in this scenario. So, here we know that whatever component comes from the withList higher-order component will be of the List type.

All of the initial props, excluding the ones we know we're passing in ourself from this higher-order component. Omit from the type of the component we passed in, all of the keys with character props. We're just reading the props of whichever component is passed in and producing a new prop type dynamically. That's everything it originally took, except the items we know we're passing in.

function withCharacter<T>(
  Component: React.ComponentType<T>
) {
  return (props: Omit<T, keyof WithCharacterProps>) => {
      // implementation code
  };
}

We can also say that T should extend with ListProps. What this will do is now, withList, we'll only take components that originally took a user list prop. It will be a generic type. Our sole requirement is that the generic type be of a type with a list attribute. As a result, you can no longer send items into withList that do not take a list property at all.

function withCharacter<T extends WithListProps>(
  Component: React.ComponentType<T>
) {
  return (props: Omit<T, keyof WithCharacterProps>) => {
      // implementation code
  };
}

This taught us: How can utility types generate types dynamically? How can you decouple state management from your React component?

We typed a HOC successfully. The final code is:


function withCharacter<T extends WithListProps>(
  Component: React.ComponentType<T>
) {
  return (props: Omit<T, keyof WithCharacterProps>) => {
    const [list, setlist] = useState<ListType | null>(null);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
      fetchList().then((result) => {
        setlist(result);
        setLoading(false);
      });
    }, []);

    if (loading) return <Loading />;
    return list && <Component {...props} list={list} />;
  };
}

5. Polymorphic components

A polymorphic component can transform into any element depending on how it is used. You may, for example, design a Box component that renders a div, label, input, button, or any HTML element. This is usually accomplished by passing a as or is prop.

One of the first instances of Polymorphic components that comes to mind is the as prop in Chakra UI Library.

image.png

Why would we want to do anything like this?

You most certainly have a theme, colour modes, and utility properties if you are designing reusable components for a css library or a project. You want avoid adding colour modes and utility properties to every new component as this adds a lot of unneeded boilerplate to your codebase.

A better approach would be to place all of your themes and upgraded props in the Box component and then construct all other components around it.For example, I f you want to modify your variations or props, you only need to do it once in the Box, and it will be applied globally.

How to create an polymorphic component?

Assume we had a text element, and right now it always returns a div; we want to create a semantically improved text element that we could pass in as prop, and it would transform into a paragraph tag or a span.

import * as React from "react";

type TextProps = {
  children: string;
} & React.ComponentPropsWithoutRef<"div">;

const exampleText =
  "lorem ispumm.....";

function Text({ children, ...rest }: TextProps) {
  return <div {...rest}>{children}</div>;
}

const Application = () => {
  return (
    <main>
      <Text id="main">{exampleText}</Text>
    </main>
  );
};

export default Application;

We only have one prop in 'TextProps,' but we want to send all acceptable attributes. To do this, we use a type provided by React called ComponentPropsWithoutRef. That implies providing the component with all valid characteristics such as id, class, and so forth. We do this by combining the types with an intersection.

Let's begin by transforming the 'Text' component into a polymorphic component:

We'll make a 'TextOwnProps' type that includes 'children' and the 'as' prop. The "T" may be anything; it's simply a variable that informs typescript that we will be providing a React Element to the as prop, which can be HTML or a React component.

type TextOwnProps<T extends React.ElementType> = {
  children: string;
  as?: T;
} & React.ComponentPropsWithoutRef<"div">;

We will now define a new type called TextProps.

type TextProps<E extends React.ElementType> = TextOwnProps<E> &
  Omit<React.ComponentProps<E>, keyof TextOwnProps>;

This indicates that it combines everything in our div with every element in the react component, except for the ones defined in 'TextOwnProps.'

Let's finish by adding the TextProps type to our Text component using the 'as' prop.

function Text<E extends React.ElementType>({
  children,
  as,
  ...rest
}: TextProps<E>) {
  const TagName = as || "div";
  return <TagName {...rest}>{children}</TagName>;
}

We'll need to alter the component a little. By default, TagName will be a 'div'; otherwise, it will be whatever we supply in as. Then, add the TagName instead of 'div'.

We can now send any element into as and it will function as expected.

image.png

image.png

Awesome! We've arrived at the end! Thank you again for your patience if you've made it this far.

Tell me about one new topic you discovered through this blog that you were previously unaware of. I'd appreciate your response and feedback:)

Did you find this article valuable?

Support Prerana Nawar by becoming a sponsor. Any amount is appreciated!