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;
}
  1. Notice the =, similar to the rest of JavaScript this allows for setting a default value if one is not specified, in this case the type Node. This allows for the usage of the type NodeProps on its own which will evaluate to NodeProps<Node>. Alternatively, we can enforce our own type like NodeProps<MyCustomNode>, as long as MyCustomNode extends Node>.
  2. NodeType is just a name, like T.
  3. NodeType extends Node is a constraint (T extends Node) enforcing that T is a subtype of Node.

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
>
  1. NodeData extends Record<string, unknown> ensures the NodeData resembles a dictionary where the keys are strings and the values may be anything as long as they are eventually typed.
  2. = Record<string, unknown> is a default type if NodeData isn’t specified. In the context of React Flow, this means data will be typed Record<string, unknown>, which while useful, doesn’t actually provide type safety for any members of data. Instead, we’d want to explicitly define a type for our data like { label: string }.
  3. NodeType extends string | undefined is used internally by React Flow to set .type for the node for any case where you may set type: NodeType when using custom components. This isn’t strictly necessary but offers nice features like autocomplete and enforcing type constraints like type: 'binary' | 'math' | 'graph' .
  4. = 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 }
  1. I did not explicitly define the extends for Node’s NodeData and NodeType like before for the sake of brevity, however, NodeBase has the exact same extends ... constraints as Node. It’s important to realize that TypeScript doesn’t “inherit” type constraints from inner types when defining this wrapper for NodeBase, so they do in fact need to be explicitly re-written. Otherwise it would allow for type Node<number, boolean> to compile, but error out during runtime.
  2. 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.