Props
Classically, we can destructure props into their individual properties, or grab them with props.<property>
. But how do we type those? There are 2 main ways. In most cases, it’s great to create new props type for your component. However, in simpler cases it can be acceptable to directly define the types inline.
// 1. Create a new prop type
type MyProps = {
course: string;
};
const MyComponent = (props: MyProps) => { return <h1>props.course</h1> }; // Entire object
const MyComponent = ({ course }: MyProps) => { return <h1>course</h1> }; // Destructured object
// 2. Inline typing
const MyComponent = (props: { course: string }) => { return <h1></h1> }; // Entire object
const MyComponent = ({ course }: { course: string }) => { return <h1></h1> }; // Destructured object
Generics
Things can start to get a little complicated. Let’s look at this code snippet from the React Flow library where we want to extend the type NodeProps<T>
by adding some fields to data
and giving those fields types.
export type NodeProps<NodeType extends Node = Node> = {
id: string;
data: Node['data'];
dragHandle?: boolean;
}
- Notice the
=
, similar to the rest of JavaScript this allows for setting a default value if one is not specified, in this case the typeNode
. This allows for the usage of the typeNodeProps
on its own which will evaluate toNodeProps<Node>
. Alternatively, we can enforce our own type likeNodeProps<MyCustomNode>
, as long asMyCustomNode
extendsNode>
. NodeType
is just a name, likeT
.NodeType extends Node
is a constraint (T extends Node
) enforcing thatT
is a subtype ofNode
.
How can we create a NodeType that extends Node in this case?
Let’s look at the type definition of Node
here. What’s going on?
type Node<
NodeData extends Record<string, unknown> = Record<string, unknown>,
NodeType extends string | undefined = string | undefined
>
NodeData extends Record<string, unknown>
ensures theNodeData
resembles a dictionary where the keys are strings and the values may be anything as long as they are eventually typed.= Record<string, unknown>
is a default type ifNodeData
isn’t specified. In the context of React Flow, this meansdata
will be typedRecord<string, unknown>
, which while useful, doesn’t actually provide type safety for any members ofdata
. Instead, we’d want to explicitly define a type for ourdata
like{ label: string }
.NodeType extends string | undefined
is used internally by React Flow to set.type
for the node for any case where you may settype: NodeType
when using custom components. This isn’t strictly necessary but offers nice features like autocomplete and enforcing type constraints liketype: 'binary' | 'math' | 'graph'
.= string | undefined
is used to internally say the custom node type can be any string or unknown.
Simply put, this breaks down to type Node<Data = unknown, Type extends string | undefined>
where we want to explicitly define unknown
.
One last thing
In React Flow, the full type definition actually looks like the following, notice the addition of =
and what follows.
type Node<NodeData, NodeType> = NodeBase<NodeData, NodeType> & { ...a bunch of optional fields }
- I did not explicitly define the
extends
for Node’sNodeData
andNodeType
like before for the sake of brevity, however,NodeBase
has the exact sameextends ...
constraints asNode
. It’s important to realize that TypeScript doesn’t “inherit” type constraints from inner types when defining this wrapper forNodeBase
, so they do in fact need to be explicitly re-written. Otherwise it would allow fortype Node<number, boolean>
to compile, but error out during runtime. - Here I am demonstrating type composition using
&
which merges additional fields/types.
TypeScript Concept: unknown
vs any
In summary, unknown
is the type safe counterpart to any
. Anything may be assigned to unknown
, but unknown
isn’t assignable to anything other than itself and any
without a type assertion. Likewise, no operations are permitted on an unknown
with first asserting or narrowing to a more specific type. any
effectively means “disable type check”.
When to use it
Use for APIs that want to signal “this can be any type, but you must perform some sort of type checking before you use it”.
Examples
let vAny: any = 10; // We can assign anything to any
let vUnknown: unknown = 10; // We can assign anything to unknown just like any
let s1: string = vAny; // Any is assignable to anything
let s2: string = vUnknown; // Invalid; we can't assign vUnknown to any other type (without an explicit assertion)
vAny.method(); // Ok; anything goes with any
vUnknown.method(); // Not ok; we don't know anything about this variable
TypeScript Concept: undefined
vs null
They both represent the absence of value. undefined
indicates a variable that has been declared but not yet set whereas null
has intentionally been set to represent no meaningful value.
TypeScript Concept: Discriminated Unions
This is a type consisting of all possible variants of your type. The key here is that each variant has a type
discriminator which can be a string, number, or anything really. Why is it important?
Let’s say we have this type definition:
type Vehicle = {
type: 'motorbike' | 'car';
make: string;
model: string;
fuel: 'petrol' | 'diesel',
doors?: number;
bootSize?: number;
}
This is weak, and somebody could easily come along with a Vehicle of “type” motorbike, but also say that it has 4 doors. Let’s instead split up motorbike
and car
into their own types.
type MotorBike = {
type: 'motorbike';
fuel: 'petrol';
}
type Car = {
type: 'car';
doors: number;
bootSize: number;
}
Much better! Now we can make a type Vehicle = MotorBike | Car
and the IDE will enforce the constraints of their respective types. You don’t even have to explicitly define named types for MotorBike and Car, you can just directly add them, union them, and ensure they have discriminators and boom bada bang we’re done. If you’re doing some work on a Vehicle
instance, and you’re afraid of accidentally putting a door on a motorbike, don’t worry! You can just check vehicleInstance.type === 'car'
.
TypeScript Concept: never
check
never
represents values that are never expected to occur. Let’s say you have a switch statement where you may add cases later. If you update your union type to include a new type that we should account for, it’s entirely possible we’ll forget to update the switch statement. However, if we put const _exhaustiveCheck: never = action;
in the default case where action is an instance of our union type, TypeScript will throw an error if it encounters a new type.