How to Implemented Conditional Fields In TypeScript?

Adam Zieliński Avatar

Posted by

on

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!

Leave a Reply

Blog at WordPress.com.

%d bloggers like this: