Search code examples
javascripttypescripttypestypeguards

Link two independend types to detect correct typings


I have the following Document structure:

onst blockTypes = [
    'Title',
    'Image',
] as const;
type BlockType = typeof blockTypes[number];

interface IDocumentBlock {
    id: string;
    type: BlockType;
    position: number;
}

interface IConfigurableDocumentBlock<T> extends IDocumentBlock {
    config: T;
}

interface ITitleBlockConfig {
    size: number;
    subtitle: boolean;
}
interface ITitleBlock
    extends IConfigurableDocumentBlock<ITitleBlockConfig> {
    type: 'Title';
    content: string;
}

interface IImageBlockConfig {
    alignment: 'center' | 'left' | 'right';
}
interface IImageBlock
    extends IConfigurableDocumentBlock<IImageBlockConfig> {
    type: 'Image';
    source: string;
    title?: string;
}

type DocumentBlock =
    | IImageBlock
    | ITitleBlock
    
const isConfigurableBlock = (
    block: IDocumentBlock
): block is IConfigurableDocumentBlock<unknown> => {
    return (block as IConfigurableDocumentBlock<unknown>).config !== undefined;
};

For blocks which are configurable there is an BlockConfigurator type which can be passed to anywhere to configure those blocks:

type BlockConfigurator<
    T extends IConfigurableDocumentBlock<unknown>,
    V extends keyof T['config']
> = T extends IConfigurableDocumentBlock<infer R>
    ? {
            blockType: T['type'];
            title: string;
            parameter: V;
            value: T['config'][V] | ((config: R) => T['config'][V]);
      }
    : never;

const ImageBlockConfigurator: BlockConfigurator<IImageBlock, 'alignment'> =
    {
        blockType: 'Image',
        title: 'Right',
        parameter: 'alignment',
        value: (config) => (config.alignment === 'center' ? 'left' : 'right'),
    };
    

const TitleBlockConfigurator: BlockConfigurator<ITitleBlock, 'size'> = {
    blockType: 'Title',
    title: 'Font Size 2',
    parameter: 'size',
    value: 2,
};

type Configurator =
    | typeof ImageBlockConfigurator
    | typeof TitleBlockConfigurator;

I'm having a bit of trouble connecting those two types. I know any Object I get which is an BlockConfigurator HAS to have valid parameter and value types so I should never get an error if I just check the block type. But the cast to any looks really ugly and I was wondering if there is a way to write a smarter typeguard?

const configureBlock = (block: DocumentBlock, configurator: Configurator) => {
    if (
        isConfigurableBlock(block) &&
        block.type === configurator.blockType
    ) {
        const value =
            typeof configurator.value === 'function'
                ? configurator.value(block.config as any)
                : configurator.value;
        (block.config as any)[configurator.parameter] = value;
    }
}

Here is a link to a playground file if it helps.

E: For example this would eliminate the any cast but I would have to manually write any parameter value and blocktype combination:

if (
    isConfigurableBlockNormalized(block) &&
    block.type === configurator.blockType
) {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    if (
        configurator.blockType === 'Title' &&
        configurator.parameter === 'size' &&
        block.type === 'Title'
    ) {
        const value =
            typeof configurator.value === 'function'
                ? configurator.value(block.config)
                : configurator.value;
        block.config[configurator.parameter] = value;
    }
}

The contents of the if block would be exactly the same for any combination, just the condition would change. But those conditions are baked in to the Configurator itself, and have to be true.


Solution

  • This is tough. I can understand the problem, which is that block.type === configurator.blockType does not guard the types in way where TypeScript knows that configurator.value and block.config must be a matching pair.

    Your current isConfigurableBlock check is only useful in this configureBlock function as a runtime safeguard. It is already known that all members of the DocumentBlock union are configurable as both IImageBlock and ITitleBlock extend IConfigurableDocumentBlock. So isConfigurableBlock(block) must always be true.

    What we need to be checking is that the block variable is configurable by this specific configurator variable.


    My first approach was to use a higher-order function to create a type guard that is specific to the configurator. This is a generic mess, asserts things that are beyond the scope of what's actually checked, and still doesn't work. For reference:

    // do not try this at home -- bad code below
    const isConfigurableBlock = <Config, Key extends keyof Config, Block extends IConfigurableDocumentBlock<Config>>(
        configurator: BlockConfigurator<Block, Key>
    ) => (block: IConfigurableDocumentBlock<unknown>): block is IConfigurableDocumentBlock<Config> => {
        return block.type === configurator.blockType;
    }
    

    Then I took a more "back to the drawing board" look at these types. The current check just looks at the blockType and relies on the assumption that members of the DocumentBlock union with a particular block type have the same config type as members of the Configurator union with that block type. This is currently true, but not inherently or automatically true.

    We want to make that relationship more explicit. We can do that by using DocumentBlock as the canonical reference point for the relationship.

    type ConfigForType<T extends BlockType> = Extract<DocumentBlock, {type: T}>['config']
    
    type Check = ConfigForType<'Image'> // inferred as IImageBlockConfig -- correct
    

    I would drop one level of extends and have the blocks extend IDocumentBlock directly rather than going through IConfigurableDocumentBlock. Note that ITitleBlock is still assignable to IConfigurableDocumentBlock<ITitleBlockConfig> since it meets the conditions of that type, regardless of whether it extends it.

    interface ITitleBlock extends IDocumentBlock {
        type: 'Title';
        config: ITitleBlockConfig
        content: string;
    }
    

    The BlockConfigurator generic depends on the entire block object ITitleBlock but the only properties that it actually looks at are the type and config. It can configure a block regardless of whether that block has the content property which is required of an ITitleBlock.

    We can actually remove the conditional from the BlockConfigurator type. It just needs to know the BlockType and the property name. It can get the config from the previously-established canonical relationship.

    type BlockConfigurator<
        T extends BlockType,
        V extends keyof ConfigForType<T>
    > = {
        blockType: T;
        title: string;
        parameter: V;
        value: ConfigForType<T>[V] | ((config: ConfigForType<T>) => ConfigForType<T>[V]);
    }
    

    This also makes it much easier to infer the generics of the BlockConfigurator since they are both strings which are directly present in the object. There is no longer any unknown outside information which needs to be specified.

    You can use direct assignment like you had before:

    const TitleBlockConfiguratorT2: BlockConfigurator<'Title', 'size'> = { ...
    

    But you can also do this:

    // identity function which validates types
    const createBlockConfigurator = <
        T extends BlockType,
        V extends keyof ConfigForType<T>
    >(config: BlockConfigurator<T, V>) => config;
    
    // type is inferred as BlockConfigurator<"Title", "size">
    const TitleBlockConfiguratorT2 = createBlockConfigurator({
        blockType: 'Title',
        title: 'Font Size 2',
        parameter: 'size',
        // type of config is inferred as ITitleBlockConfig
        value: (config) => config.size,
    });
    

    This configureBlock function is a real pain though. Not even my go-to union of valid pairings trick works here. If I hadn't already invested enough time and energy I would have given up at this point. But I have, so I've to find something that works even if it's not perfect.

    The problem with applying the higher-order function approach here is that the generic of the first function won't be specific since our configurator argument is of type Configurator. In order to get that specificity, we can attach the type guard to the individual configurator objects. We can use the createBlockConfigurator function demonstrated earlier, but modify it to include a property which is a type guard.

    const createBlockConfigurator = <
        T extends BlockType,
        V extends keyof ConfigForType<T>
    >(config: BlockConfigurator<T, V>) => ({
        ...config,
        canConfigure: <B extends DocumentBlock>(block: B): block is B & { type: T; config: ConfigForType<T>; } => 
            block.type === config.blockType
    });
    

    This still doesn't work. So now I'm mad as hell and I need to defeat the red squiggles.

    The next step is to call the function through a property of the configurator object.

    I am confused by what you are doing here block.config[configurator.parameter] = value;. With the value creator function on the configurator, you are using block.config as the basis for the value. But you are also using the value as the basis for setting block.config.

    Pick one or the other. Perhaps the block that we are processing doesn't have the config property yet and we are creating it -- but then this whole question is moot.

    So for now I'm just assuming that you want to potentially call a function that takes the config from the block and creates a value. I'm ignoring the illogical thing that you are doing with that computed value.

    When provided with an invalid block argument, the configurator can either throw an Error or return some value like null or undefined that you later check for.

    const createBlockConfigurator = <
        T extends BlockType,
        V extends keyof ConfigForType<T>
    >(config: BlockConfigurator<T, V>) => {
        const canConfigure = <B extends DocumentBlock>(block: B): block is B & { type: T; config: ConfigForType<T>; } => 
            block.type === config.blockType;
        const computeValue = (block: DocumentBlock): ConfigForType<T>[V] => {
            if ( ! canConfigure(block) ) {
                throw new Error(`Invalid block object. Expected type ${config.blockType} but received type ${block.type}`);
            }
            // error on the next line
            return ( typeof config.value === "function" ) ? config.value(block.config) : config.value;
        }
        return {
            ...config,
            canConfigure,
            computeValue,
        }
    };
    
    const configureBlock = (block: DocumentBlock, configurator: Configurator) => {
        // no problems here
        const value = configurator.computeValue(block);
    }
    

    TypeScript Playground Link

    Now we're almost there except I keep getting infuriating errors, like this one:

    Not all constituents of type '((config: ConfigForType) => ConfigForType[V]) | (ConfigForType[V] & Function)' are callable.

    Type 'ConfigForType[V] & Function' has no call signatures.

    I believe that this error is due to the possibility that the value property which we are computing is itself a function. We know that never happens, but I believe that ConfigForType<T>[V] is not evaluated fully to a union of all possible constituents since it is a generic.

    So...I guess we can make that assertion with another type guard? But it's starting to become a real mess and also starting to look like a job for a class. And I can't even get that guard to work so now I officially give up.