I implemented a TypeScript interface with conditionally removed fields as a part of adding TypeScript support to WordPress:
const post_with_status: Post<'edit'> = …
// post_with_status.status is a string
const post_without_status: Post<'embed'> = …
// post_without_status.status field does not exist
WordPress data comes in different flavors
A common mistake Gutenberg developers make is assuming the data returned by the WordPress REST API always have the same shape.
Here’s a post fetched from /wp/v2/posts/1?context=view
:
{
"id": 1,
"status": "published",
// ...other fields...
}
And here’s one fetched from /wp/v2/posts/1?context=embed
:
{
"id": 1,
// There is no "status" field!
// ...other fields...
}
Oftentimes the code makes some decisions based on a conditional field like post.status
without realizing that field may be missing.
Optional fields don’t provide guidance
One way to handle that would be to use an optional field:
interface Post {
id: number;
status?: string;
}
Unfortunately, this type a bit of a lie. It makes developers guess whether the status
is present on a given post
object or not.
It’s just too easy to guess wrong. TypeScript is supposed to reduce mistakes so let’s create a type that would provide more guidance.
Conditional fields tell you exactly what to expect
Status isn’t optional. It is always given in the edit context and always missing in the embed context. Let’s say it in TypeScript:
type Context = 'view' | 'edit' | 'embed';
interface BasePost< C extends Context > {
id: number;
status: C extends 'view' | 'edit' ? string : never;
}
One problem with this implementation is that the BasePost< 'embed' >['status']
is of type never
. Luckily, there’s a way to remove all such fields:
type OmitNever< T > = {
[ K in keyof T as T[ K ] extends never ? never : K ]: T[ K ];
};
type Post< C extends Context > = OmitNevers< BasePost< C > >;
const post: Post<'edit'> = …
// post.status is a string
const post: Post<'embed'> = …
// there is no post.status field
The OmitNever< T >
type copies all fields from Post
except the ones of the never
type.
Now, the types are a source of information. They communicate exactly what details are available on a given post. That’s a much better experience!
Finally, adding a named wrapper makes the BasePost type read nicer:
export type ContextualField<
FieldType,
AvailableInContexts extends Context,
C extends Context
> = AvailableInContexts extends C ? FieldType : never;
interface BasePost< C extends Context > {
id: number;
status: ContextualField< string, 'view' | 'edit', C >
}
Modeling real WordPress data requires recursively conditional fields
In reality, WordPress REST API also has deeply nested conditional fields such as below:
interface BasePost< C extends Context > { {
id: number;
content: {
rendered: string;
raw: ContextualField< string, 'edit', C >
};
}
The OmitNevers
type I proposed earlier won’t remove the BasePost< 'view' >['content']['raw']
field that’s now of type never
. However, this one will:
export type OmitNevers<
T,
Nevers = {
[ K in keyof T ]: Exclude< T[ K ], undefined > extends never
? never
: T[ K ] extends Record< string, unknown >
? OmitNevers< T[ K ] >
: T[ K ];
}
> = Pick<
Nevers,
{
[ K in keyof Nevers ]: Nevers[ K ] extends never ? never : K;
}[ keyof Nevers ]
>;
type Post< C extends Context > = OmitNevers< BasePost< C >
const post: Post<'edit'> = …
// post.content.raw is a string
const post: Post<'view'> = …
// post.content.raw does not exist
Putting it all together
Here’s the complete code snippet (open in TypeScript playground):
export type ContextualField<
FieldType,
AvailableInContexts extends Context,
C extends Context
> = AvailableInContexts extends C ? FieldType : never;
type Context = 'view' | 'edit' | 'embed';
interface BasePost< C extends Context > {
id: number;
content: {
rendered: string;
raw: ContextualField< string, 'edit', C >
};
}
export type OmitNevers<
T,
Nevers = {
[ K in keyof T ]: Exclude< T[ K ], undefined > extends never
? never
: T[ K ] extends Record< string, unknown >
? OmitNevers< T[ K ] >
: T[ K ];
}
> = Pick<
Nevers,
{
[ K in keyof Nevers ]: Nevers[ K ] extends never ? never : K;
}[ keyof Nevers ]
>;
type Post< C extends Context > = OmitNevers< BasePost< C >
const post: Post<'edit'> = …
// post.content.raw is a string
const post: Post<'view'> = …
// post.content.raw does not exist
I hope you will find it useful!