We are currently porting our portfolio from Javascript to TypeScript using NextJS as frontend framework and Strapi as backend.
To have dynamic content we created a dynamiczone field inside of the post model and we get it from GraphQL.
Our problem comes when we want to render the content based on the dynamic zone type, its model is:
export interface IExperience {
id: string;
from: Date;
to?: Date;
ongoing?: boolean;
title: string;
institution: string;
address?: IAddress;
url?: string;
description?: string;
}
export interface IPersonalInformation {
id: string;
name: string;
photo: IFile;
position: string;
nationality?: string;
address?: IAddress;
telephone?: ITelephone[];
mail: string;
links?: ISocialLink[];
aboutMe?: string;
}
export interface IRichText {
id: string;
text: string;
}
export type IComponent =
| ({
__component: "content.rich-text";
__typename: "ComponentContentRichText";
} & IRichText)
| ({
__component: "content.experience";
__typename: "ComponentContentExperience";
} & IExperience)
| ({
__component: "content.personal-information";
__typename: "ComponentContentPersonalInformation";
} & IPersonalInformation)
| ({
__component: "fields.skill";
__typename: "ComponentFieldsSkill";
} & ISkill);
The component field will extends one interface based in its type; cool, but when we go to render it we get problems:
const DynamicZone: React.FC<IDynamicZone> = ({ component, className }) => {
const classes = useStyles();
const selectComponent = () => {
switch (component.__typename) {
case "ComponentContentRichText":
return <Content>{component.text}</Content>;
case "ComponentContentExperience":
return <Experience {...component} />;
case "ComponentContentPersonalInformation":
return <PersonalInformation {...component} />;
case "ComponentFieldsSkill":
return <Skill {...component} />;
}
};
return (
<Typography
variant="body1"
component="section"
className={clsx(classes.dynamicZone, className)}
>
{
{
"content.rich-text": <Content>{component.text}</Content>, <-- Bug 1
"content.experience": <Experience {...component} />,
"content.personal-information": (
<PersonalInformation {...component} /> <-- Bug 2
),
"fields.skill": <Skill {...component} />,
}[component.__component]
}
</Typography>
);
};
export default DynamicZone;
With, bug 1:
<html>TS2339: Property 'text' does not exist on type 'IComponent'.<br/>Property 'text' does not exist on type '{ __component: "content.experience"; __typename: "ComponentContentExperience"; } & IExperience'.
And bug 2:
<html>TS2322: Type '{ __component: "content.rich-text"; __typename: "ComponentContentRichText"; id: string; text: string; } | { __component: "content.experience"; __typename: "ComponentContentExperience"; ... 8 more ...; description?: string | undefined; } | { ...; } | { ...; }' is not assignable to type 'IntrinsicAttributes & IPersonalInformation & { children?: ReactNode; }'.<br/>Type '{ __component: "content.rich-text"; __typename: "ComponentContentRichText"; id: string; text: string; }' is missing the following properties from type 'IPersonalInformation': name, photo, position, mail
Why is it assing the type improperly?
Ok, if we change it to selectComponent function, it does not give any error:
const DynamicZone: React.FC<IDynamicZone> = ({ component, className }) => {
const classes = useStyles();
const selectComponent = () => {
switch (component.__typename) {
case "ComponentContentRichText":
return <Content>{component.text}</Content>;
case "ComponentContentExperience":
return <Experience {...component} />;
case "ComponentContentPersonalInformation":
return <PersonalInformation {...component} />;
case "ComponentFieldsSkill":
return <Skill {...component} />;
}
};
return (
<Typography
variant="body1"
component="section"
className={clsx(classes.dynamicZone, className)}
>
{selectComponent()}
</Typography>
);
};
Esentially, it is the same thing, so, why it does not give typing errors with switch case but it does with {{}[]}?
Thanks.
You have provided a type guard in selectComponent() by switching between the values of component.__typename, which narrows the type that component can be. Since the case of "ComponentContentRichText" can only narrow down to a single type in the IComponent union,
{ __component: "content.rich-text"; __typename: "ComponentContentRichText"; } & IRichText,
it is known that the text property exists on component via the IRichText interface.
In the example of dynamic selection with an object and index ({...}[...]), you have not narrowed the type of component. The object is created with all of the members - regardless of the component.__component value - and then the value is selected dynamically with an index of the component.__component value. The transpiler cannot tell that text is a valid property of component at the time that the object is created.
You could add type guards in the dynamic selection's object instantiation using conditional statements. However, this method is not optimal because there are additional run-time checks to be made.
<Typography>
{
{
'content.rich-text': component.__component === 'content.rich-text'
? <Content>{ component.text }</Content>
: undefined,
/* ... */
}[component.__component]
}
</Typography>
Or, you could use chained conditional statements to select the result.
<Typography>
{
component.__component === 'content.rich-text'
? <Content>{ component.text }</Content>
: component.__component === 'content.experience'
? <Experience { ...component } />
: /* ... */
}
</Typography>
An alternative to the selectComponent() anonymous function to keep the definition inline with the JSX, would be to define an anonymous function and call it immediately. Using a function - whether named, anonymous and defined earlier, or anonymous and called immediately - has the added benefits of being cleaner, easier to reason about, and being able to use switch statement optimizations such as jump tables.
<Typography>
{
function() {
switch (component.__component) {
case 'content.rich-text':
return <Content>{ component.text }</Content>
/* ... */
default:
return null;
}
}()
}
</Typography>
Related
Two parent components are sharing the same child components, but have differen properties passed.
Since ParentB doesn't pass the name props, it throws the error
type '{ mail: any; }' is missing the following properties from type '{ mail: string; name: string; } name
See the example:
const ParentA=()=>{
return (
<>
<Child
mail={project.mail}
name={name}
/>
</>
)
}
const ParentB=()=>{
return (
<>
<Child
mail={project.mail}
/>
</>
)
}
const Child: FunctionComponent<{
mail: string;
name: string;
}> = ({ mail, name }) => {
}
I tried to solve it this way in Child component, but it throws another error in jsx
interface ChildProps {
mail: string;
name: boolean;
}
interface CHildWitouthNameProps {
mail: string;
name?: never;
}
type Props = ChildProps | CHildWitouthNameProps;
const Child = (props: Props) => {
Another try was
const Child: FunctionComponent<{
mail: string;
name?: boolean;
}> = ({ mail, name }) => {
}
But it throws another error in jsx
name is possibly 'undefined'
return (
<div>
{name}
</div>
)
How to fix the error?
Any Help will be appreciated.
Your last approach
const Child: FunctionComponent<{
mail: string;
name?: boolean;
}> = ({ mail, name }) => {
}
seems correct, since logically the child component must have a mail prop, but it doesn't necessarily need to have a name prop. Based on this definition, the second error you describe,
name is possibly 'undefined'
seems like TypeScript functioning as intended. It's telling you that there is a prop which might not be defined, being used in a way that would break the application if it actually was undefined. The way to fix this is by ensuring that wherever you use the name prop in a way that's sensitive to whether or not it's defined, you should include good checks. There are a couple ways to do this:
return (
<div>
{name || "No name given"}
</div>
)
^this way fills in the name with the placeholder whenever it's not present,
return (
{name && <div>
{name}
</div>}
)
^this way only renders the whole div if name is defined to begin with.
Again, it seems like it depends on what you're going for, but this might be some good things to try first.
Just add ? which means that that is an optional key.
const ParentA=()=>{
return (
<>
<Child
mail={project.mail}
loading={loading}
/>
</>
)
}
const ParentB=()=>{
return (
<>
<Child
mail={project.mail}
/>
</>
)
}
const Child: FunctionComponent<{
mail: string;
loading?: boolean;
}> = ({ mail, loadedData }) => {
}
I really like uing this pattern for rendering similar components and using a _type prop to distinguish it and pass it down to the correct component.
However, I've found it difficult to add in the types correctly and was wondering if you guys could help. I have some questions;
Is the BlockMap type correct?
What type should I be using for ResolvedBlock?
Or generally, is there a better way of writing the types (without changing this structure?)
import React from 'react'
import { ImageBlock } from '/ImageBlock' // Assume all components are imported from whereever
type BlockType =
| 'imageBlock'
| 'formBlock'
| 'statisticBlock'
| 'videoBlock'
| 'quoteBlock'
interface Block {
_type: BlockType
_key: string
heading?: string
backgroundColor?: string
theme?: 'dark' | 'light'
}
type BlockMap = Record<BlockType, JSX.Element> // Is this type correct?
const blockMap:BlockMap = {
imageBlock: ImageBlock,
formBlock: FormBlock,
statisticBlock: StatisticBlock,
videoBlock: VideoBlock,
quoteBlock: QuoteBlock,
}
interface Props {
className?: string
blocks: Block[]
}
export function BlocksBuilder({
blocks = [],
className = ``,
}: Props):JSX.Element {
return (
<>
{blocks.map(block => {
const ResolvedBlock = blockMap[block._type] // What type should ResolvedBlock be?
if (!ResolvedBlock) return null
return (
<ResolvedBlock
className={className}
block={block}
key={block._key}
/>
)
})}
</>
)
}
It's a good pattern, and your type is close, but you will want to define the shape of your components instead.
type BlockMap = Record<BlockType, (props: any) => JSX.Element>
You could also define the props for Block components, and use that type for each of your components
interface BlockProps {
key: string;
block: Block;
classname: string;
}
type BlockComponent = (props: BlockProps) => JSX.Element;
export const ImageBlock: BlockComponent = (props) => {
return <></>;
};
type BlockMap = Record<BlockType, BlockComponent>
I seem to have encountered an odd scenario where the Typescript compiler is confused by the passing of the children prop to component, resulting in unsafe behaviour.
I want to have a component which can accept subtitle (text) and subtleSubtitle (boolean which affects the style) props ONLY if a title prop is also passed. If a subtitle is passed but no title, this should be a Typescript error.
However, when trying to implement this I found that Typescript seems to allow invalid props to be passed to the component.
Here is the code
import React from 'react'
// subtitle and subtleSubtitle are only permissible when title is present.
type Props =
| {
children: React.ReactNode;
title: string;
subtitle: string;
subtleSubtitle?: boolean
}
| { children: React.ReactNode; title?: string }
// Plain function to test behaviour outside of React components
const myFunc = (props: Props) => {
if (props.title && 'subtitle' in props) {
console.log(props.title + props.subtitle)
return;
}
console.log(props.title)
return;
}
// Component which accepts the same props
const MyComponent: React.FC<Props> = (props) => {
if (props.title && 'subtitle' in props) {
return (<div>
<h1>{props.title}</h1>
<p style={{ color: props.subtleSubtitle ? "grey" : "black" }}>{props.subtitle}</p>
</div>)
}
return <h1>{props.title}</h1>
}
myFunc({ subtitle: "some text" }) // Expect error as subtitle can only be passed if title is passed
myFunc({ subtitle: "some text", children: <p>hi</p> }) // Expected same error but there is none
// same as above, expect an error
const MyParentComponent = () => {
return <MyComponent subtitle="some text" />
}
// Expected error but there is none
const MyOtherParentComponent = () => {
return <MyComponent subtitle="some text">Hello</MyComponent>
}
Playground link:
https://www.typescriptlang.org/play?#code/JYWwDg9gTgLgBAJQKYEMDG8BmUIjgcilQ3wChSYBPMJOABRzAGc4BeOAbzjQAtgAbACZEAdgC5ExGADpk6GADkIgpAG44MYDH5IJTGFGAiA5uqYBXAEabtuuPsMmzV2wGUXWnQH4JliBB0UETgAXzgAH05uPiFRCTkMWSklFXUbbz0DI2NQ8gB6PPp+FCM4THMRDGAIYJgIDSR9OC04AHdoAGsWCHMYJmAVOAhMSXluXEgRJBE+0jQappBKADEKtDY4AAowRiYJBghmAEo2AD5OUjhmke3d6XTaADJHggtrTyR8ZuCdw6YTjiXK7jERMAJIaT8CDGW5-e4fOAAajgv2Y0jeDyOQKuRBg5igIlUQJC5Cu81B4Mh0NhaMxQNx+MJpBJpAKcAAwhMatN4K0+Lw4Og0EgwH0NDxaEwUCBaKimHMFvAALKUTngbkzeJSaTLdkAHgOzHO7Bp-zOFyuwBucvhtjgz1eHlsX1KcoB2LgDIJWz1gmAADdTh6rnqeABGU4cG0PEJ6vLhoPA4F6sD2Kg6VgcLjzKFQCQ2jE6dzvO1eOAAImMREo5bgEnLlmKaA6tZCIUjBadOljeTAieTeT9gaxVxZOKQeO9oYjUbuMbjCeZ5CWq0qmy4GI+9bBMoaAA8YK2TmyAKJ7mgYOBIKA4KCClibu1oIJDET8ShwSyylBMJhIQTXBoCLACwYA-n+gikCuazrvYXZ2OWO60DASAHuWAA00QCMI0wSCmpx8HGfahMehRnheKEAVKu7Xren69OK160CBcAiNy+SFNRtA-oKfj+kgmGoRRgrBLR0AKqCyqUHQKCiDAaqTDyGybCcrDnIC46TsEeoqgpGrwI+GaIbgyGoYecB5EGLKnueSAYP+V43tA9HwDAEpEM0LBsVMElNCqADybnXjJcl6VMMzKap6n0hOjJwDpqpcuFBnwawxm7ihaGnAAEkg-BQnGulJTyVmkEAA
Its easy to think that Union Types are exclusive, but they are actually a bit different, from Typescript docs:
It might be confusing that a union of types appears to have the intersection of those types’ properties. This is not an accident - the name union comes from type theory. The union number | string is composed by taking the union of the values from each type. Notice that given two sets with corresponding facts about each set, only the intersection of those facts applies to the union of the sets themselves. For example, if we had a room of tall people wearing hats, and another room of Spanish speakers wearing hats, after combining those rooms, the only thing we know about every person is that they must be wearing a hat.
You want your types to be exclusive, so that only one type will be passed without intersection. This can be done by using typescript's Conditional Types:
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never }
type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U
Now you can:
type Props = XOR<{ children: React.ReactNode; title: string; subtitle: string; subtleSubtitle?: boolean }, { children: React.ReactNode; title?: string }>
myFunc({ subtitle: "some text" }) // Error
myFunc({ subtitle: "some text", children: <p>hi</p> }) // Error
myFunc({ title: "cool", subtitle: "some text", children: <p>hi</p> }) // Works
I think I figured it out. Typescript doesn't prevent you from having additional properties on an object, so something like this compiles just fine:
type Props =
| {
title: string;
anotherProp: boolean;
subtitle: string;
subtleSubtitle?: boolean
}
| { anotherProp: boolean; title?: string }
const x: Props = { anotherProp: true, subtitle: "what" }
This is because { anotherProp: true } is assignable to the { anotherProp: boolean; title?: string } type. The subtitle property is ignored as an "extra" property on the object.
It should be possible to avoid this issue by structuring the union type differently or by using a tagged union (though tagged unions for props of a component is rather unusual and would be a strange DX)
I trying to create a reusable <Column /> component that displays a list of items which each dispatch a generic payload specified by the caller when clicked.
My column takes an onItemClick prop which is a function that dispatches a payload (a Redux action in my actual code). I want my function to be able to accept and dispatch a generic <PayloadType>:
type ColumnProps<PayloadType> = {
menuItems: { name: string; id: number }[];
onItemClick: (payload: PayloadType) => void;
};
const Column = <PayloadType extends {}>(
props: React.PropsWithChildren<ColumnProps<PayloadType>>
) => {
const { menuItems, onItemClick } = props;
const handleButtonClick = (menuItem: MenuItem) => {
onItemClick({ info: menuItem.name });
/*
Argument of type '{ info: string; }' is not assignable to parameter of type
'PayloadType'.
'{ info: string; }' is assignable to the constraint of type 'PayloadType', but
'PayloadType' could be instantiated with a different subtype of constraint '{}'
*/
};
return (
<>
{menuItems.map((menuItem, index) => (
<button key={index} onClick={(event) => handleButtonClick(menuItem)}>
{menuItem.name}
</button>
))}
</>
);
};
Using the component:
type MenuItem = {
name: string;
id: number;
};
const testMenuItems: MenuItem[] = [
{ name: "Vanilla", id: 0 },
{ name: "Strawberry", id: 1 },
{ name: "Chocolate", id: 2 },
{ name: "Cookies & Cream", id: 3 }
];
type ColumnPayload = {
info: string;
};
export default function App() {
const columnClickHandler = (payload: ColumnPayload) => {
console.log(`clicked: ${payload.info}`);
};
return (
<div className="App">
<Column<ColumnPayload>
menuItems={testMenuItems}
onItemClick={columnClickHandler}
/>
</div>
);
}
As seen above, I'm receiving the error:
Argument of type '{ info: string; }' is not assignable to parameter of type 'PayloadType'.
'{ info: string; }' is assignable to the constraint of type 'PayloadType', but 'PayloadType' could be instantiated with a different subtype of constraint '{}'.
How can I accept and dispatch a generic payload from my component? I'm fairly new to TypeScript so I'm not sure if I'm missing something or simply approaching the problem completely wrong.
Sandbox: https://codesandbox.io/s/sweet-kalam-tt5u5?file=/src/App.tsx
The issue here is Column cannot create a specific type without help, its only able to be aware that there is some unknown type.
Type Intersections
That being said, one way you can achieve your generic callback is simply union the payload type with the menu item.
type MenuItemWithPayload<TPayload> = MenuItem & Payload
Then dispatch the callback with the entire menuitem. I provided some example code, notice how that ColumnMenuItem is both payload and menuitem type? this allows type inferencing were you no longer need to define the payload type when using the column component.
https://codesandbox.io/s/eager-framework-pu4qe?file=/src/App.tsx.
Generic payload field
A cleaner alternative might be to allow the menu item to contain a payload field. Which is similar to the union type but uses composition.
type MenuItem<TPayload = unknown> = { name: string; id: number; payload: TPayload }
https://codesandbox.io/s/friendly-currying-g1ynk?file=/src/App.tsx
Forwarding MenuItem
Finally you can simply forward the menu item in the callback and let the parent component generate the payload it needs.
https://codesandbox.io/s/hardcore-surf-ihz96?file=/src/App.tsx
I have the component that received 2 different kind of types for the same prop.
Property for compoenent DisplayFiles
How can I inform component up front that I am passing this kind of Props or the files prop will specific kind of type?
Maybe there is other patter or way around to do it?
isAws: boolean;
files: (AwsValue | NormalFile)[];
}
AwsValue:
interface AwsValue {
bucket: string;
fileName: string;
folderPath: string;
lastModified: Date;
}
NormalFile:
export interface NormalFile {
created: Date;
fileName: string;
folderId: number;
instance: string;
modified: Date;
}
export const DisplayFiles: FunctionComponent<Props> = ({
isMetadataStep,
files,
}) => {
return (
<div>
{files.map((file: AwsValue | NormalFile) => { /* here I want tried some like isAws ? AwsValue : NormalFile, but obiously it's doesn't work */
return (
<FileItem
key={file.fileName}
title={file.fileName}
date={file.lastModified} /* here sometimes is lastModified if AwsValue or modified if NormalFile type */
isSelectable={isMetadataStep}
isSelected={selectedFile=== file.fileName}
/>
);
})}
</div>
);
};
And maybe there is possibility to pass type of property `files` in the moment of init component
Parent Component:
export const ParentOfDisplayFiles: FunctionComponent<Props> = (props) => {
return (
<div>
<FileManager isMetadataStep={false} files={filteredFiles} /> {/* passed filteredFiles sometimes are AwsValues type or NormalFile */}
</div>
);
};`
An Union type will allow you to do so. You have two choices in this case:
Keep the interfaces like that, adding a type Union like:
type File = AwsValue | NormalFile
Then add a function with a type guard in order to discriminate:
function isAwsValue(file: File): file is AwsValue {
return file.hasOwnProperty('bucket'); // Or whatever control you would like to do at runtime
}
Use a discriminated union, adding a type (or whatever name you like) property to each interface, still adding a File union type:
interface AwsValue {
type: 'aws',
// ...
}
interface NormalFile {
type: 'normal',
// ...
}
type File = AwsValue | NormalFile
Then you can just check in your code the type:
let file: File = /* ... */;
if (file.type === 'aws') {
// file.bucket, etc. Now TS will suggest you props assuming `file` is an `AwsValue`
}