26
loading...
This website collects cookies to deliver better user experience
export type DataTypes = Book | Movie | Laptop | Phone | string;
const formatLabel = (value: DataTypes) => {
if (isBook(value)) return `${value.title}: ${value.author}`;
if (isMovie(value)) return `${value.title}: ${value.releaseDate}`;
if (isLaptop(value)) return value.model;
if (isPhone(value)) return `${value.model}: ${value.manufacture}`;
return valueShouldBeString(value);
};
isBook
or isMovie
we have to do quite a lot of calculation to determine which type is where. isMovie
, for example, looks like this:export const isMovie = (value: DataTypes): value is Movie => {
return (
typeof value !== "string" &&
"id" in value &&
"releaseDate" in value &&
"title" in value
);
};
id
, two of them have releaseDate
.export type Book = {
id: string;
title: string;
author: string;
};
export type Movie = {
id: string;
title: string;
releaseDate: string;
};
... // all the other data types
__typename
already in their data. The rest would have to have some sort of normalization function that adds the correct value manually when the data is received from the external source.export const books: Book[] = [
{
__typename: "book", // add this to our json data here!
id: "1",
title: "Good omens",
author: "Terry Pratchett & Neil Gaiman"
},
///...
];
// all the rest of the data with
string
type away from DataTypes
, it will turn into what is called “discriminated union” - a union of types, all of which have a common property with some unique value.type DataTypes = Book | Movie | Laptop | Phone;
isSomething
-based implementation can be simplified into this:export type DataTypes = Book | Movie | Laptop | Phone;
const formatLabel = (value: DataTypes | string) => {
if (typeof value === "string") return value;
if (value.__typename === "book") return `${value.title}: ${value.author}`;
if (value.__typename === "movie") return `${value.title}: ${value.releaseDate}`;
if (value.__typename === "laptop") return value.model;
if (value.__typename === "phone") return `${value.model}: ${value.manufacture}`;
return "";
};
formatLabel
function call.type State = {
loading?: boolean;
error?: any;
data?: Book[];
};
const Context = React.createContext<State | undefined>(undefined);
export const BooksProvider = ({ children }: { children: ReactNode }) => {
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<any>(undefined);
const [data, setData] = useState<Book[]>();
useEffect(() => {
setLoading(true);
// just some random rest endpoint
fetch('https://raw.githubusercontent.com/mledoze/countries/master/countries.json')
.then((response) => {
if (response.status === 200) {
// in real life of course it would be the json data from the response
// hardcoding books just to simplify the example since books are already typed
setData(books);
setLoading(false);
} else {
setLoading(false);
setError(response.statusText);
}
})
.catch((e) => {
setLoading(false);
setError(e);
});
}, []);
return (
<Context.Provider
value={{
error,
data,
loading,
}}
>
{children}
</Context.Provider>
);
};
const SomeComponent = () => {
const data = useBooks();
if (!data?.data) return <>No data fetched</>;
if (data.loading) return <>Spinner</>;
if (data.error !== undefined) return <>Something bad happened!</>;
return <GenericSelect<Book> values={data.data} ... />
}
export default () => {
return (
<BooksProvider>
<SomeComponent />
</BooksProvider>
);
};
error
or data
property when loading is set to true for example, and the type system will not prevent it. On top of that, the state is split into three independent useState
, which makes it very easy to make a mistake and forget one of the states or set it to a wrong value in the flow of the function. Imagine if I forget to do setLoading(false)
or mistakenly do setLoading(true)
when I receive the data: the overall state of the provider will be loading
and data received
at the same time , the type system will not stop it, and the customer-facing UI will be a total mess.data
or error
or loading
exist heredata
or error
exist hereError
doesn’t exist hereData
doesn’t exist here.type PendingState = {
status: 'pending';
};
type LoadingState = {
status: 'loading';
};
type SuccessState = {
status: 'success';
data: Book[];
};
type ErrorState = {
status: 'error';
error: any;
};
type State = PendingState | LoadingState | SuccessState | ErrorState;
type State
is our classic discriminated union, with status
being the discriminant property: it exists in every type and always has a unique value.const defaultValue: PendingState = { status: 'pending' };
const Context = React.createContext<State>(defaultValue);
setState
instead of three independent onesconst [state, setState] = useState<State>(defaultValue);
useEffect
function to the new systemsetState({ status: 'loading' });
, typescript will not allow to set neither data
nor error
theresetState({ status: 'success' });
, typescript will fail, since it expects to find Books in the mandatory data
field for the success statesetState({ status: 'error' });
- typescript will fail here since it expects the mandatory error
field in the error stateconst SomeComponent = () => {
const data = useBooks();
if (data.status === 'pending') {
// if I try to access data.error or data.data typescript will fail
// since pending state only has "status" property
return <>Waiting for the data to fetch</>;
}
if (data.status === 'loading') {
// if I try to access data.error or data.data typescript will fail
// since loading state only has "status" property
return <>Spinner</>;
}
if (data.status === 'error') {
// data.error will be available here since error state has it as mandatory property
return <>Something bad happened!</>;
}
// we eliminated all other statuses other than "success" at this point
// so here data will always be type of "success" and we'll be able to access data.data freely
return <GenericSelect<Book> values={data.data} ... />
}
export default () => {
return (
<BooksProvider>
<SomeComponent />
</BooksProvider>
);
};
GenericSelect
component to support also multi-select functionality.type GenericSelectProps<TValue> = {
formatLabel: (value: TValue) => string;
onChange: (value: TValue) => void;
values: Readonly<TValue[]>;
};
export const GenericSelect = <TValue extends Base>(
props: GenericSelectProps<TValue>
) => {
const { values, onChange, formatLabel } = props;
const onSelectChange = (e) => {
const val = values.find(
(value) => getStringFromValue(value) === e.target.value
);
if (val) onChange(val);
};
return (
<select onChange={onSelectChange}>
{values.map((value) => (
<option
key={getStringFromValue(value)}
value={getStringFromValue(value)}
>
{formatLabel(value)}
</option>
))}
</select>
);
};
isMulti: boolean
property and then adjust implementation accordingly. In our case, we’d need to: add isMulti
to the component props, adjust onChange
callback types to accept multiple values, pass multiple
prop to the select itself, introduce internal state to hold selected values for the multi-select variation, adjust the onSelectChange
handler to support multi-select variation, filter out selected values from the rendered options and render them on top of the select instead with onDelete
handler attached.GenericSelect
props would looks like this:type GenericSelectProps<TValue> = {
isMulti: boolean;
onChange: (value: TValue | TValue[]) => void;
..// the rest are the same
};
onChange
callback, typescript would not know what exactly is in the value. There is no connection from its perspective between isMulti
prop and onChange
value, and value’s type will always be TValue | TValue[]
regardless of isMulti
property.const select = (
<GenericSelect<Book>
// I can't log "value.title" here, typescript will fail
// property "title" doesn't exist on type "Book[]""
// even if I know for sure that this is a single select
// and the type will always be just "Book"
onChange={(value) => console.info(value.title)}
isMulti={false}
...
/>
);
const multiSelect = (
<GenericSelect<Book>
// I can't iterate on the value here, typescript will fail
// property "map" doesn't exist on type "Book"
// even if I know for sure that this is a multi select
// and the type will always be "Book[]"
onChange={(value) => value.map(v => console.info(v))}
isMulti={true}
...
/>
);
GenericSelectProps
into discriminated union with isMulti
as the discriminant:type GenericSelectProps<TValue> = {
formatLabel: (value: TValue) => string;
values: Readonly<TValue[]>;
};
interface SingleSelectProps<TValue> extends GenericSelectProps<TValue> {
isMulti: false; // false, not boolean. For single select component this is always false
onChange: (value: TValue) => void;
}
interface MultiSelectProps<TValue> extends GenericSelectProps<TValue> {
isMulti: true; // true, not boolean. For multi select component this is always true
onChange: (value: TValue[]) => void;
}
export const GenericSelect = <TValue extends Base>(
props: SingleSelectProps<TValue> | MultiSelectProps<TValue>
) => {
const { isMulti, onChange } = props;
props.isMulti
and props.onChange
in the code instead. I.e. it should be something like this:if (props.isMulti) {
props.onChange([...selectedValues, val]);
if (val) props.onChange(val);
}
const select = (
<GenericSelect<Book>
// now it will work perfectly!
onChange={(value) => console.info(value.title)}
isMulti={false}
...
/>
);
const multiSelect = (
<GenericSelect<Book>
// now it will work perfectly!
onChange={(value) => value.map(v => console.info(v))}
isMulti={true}
...
/>
);