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
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.
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
.
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;
};
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.
3. Limiting Props with Function Overloads
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.
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 optionalexpanded
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.
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.
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.
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:)