Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to map objects in a discriminated union to functions they can be called with?

Tags:

typescript

In vanilla JS, I'm able to write some code that looks something like the following:

function renderTextField(props) { }
function renderSelectField(props) { }

const fieldMapping = {
    text: renderTextField,
    select: renderSelectField,
};

function renderField(field) {
    const renderFn = fieldMapping[field.type];
    renderFn(field.data);
}

Using 2 types of fields just to keep the example small, but the nice thing about this code is that the generic method doesn't need to know about the type of field, and it delegates the decision to the mapping provided by fieldMapping.

I'm trying to write something similar in TypeScript. But I can't figure out how to get the types to work and still use an object to provide a mapping between type and the function to delegate to.

I realise that I could use a switch statement or conditionals instead of an object to map things, but I would prefer to do it this way if at all possible.

type TextFieldData = { value: string }
type TextField = { type: 'text', data: TextFieldData }
type SelectFieldData = { options: string[], selectedValue: string }
type SelectField = { type: 'select', data: SelectFieldData }
type FormField = TextField | SelectField

function renderTextField(props: TextFieldData) {}
function renderSelectField(props: SelectFieldData) {}

const fieldMapping = {
  text: renderTextField,
  select: renderSelectField,
}

// This won't work!
function renderFieldDoesNotWork(field: FormField) {
  const renderFn = fieldMapping[field.type]

  // Type 'TextFieldData' is missing the following properties from type 'SelectFieldData': options, selectedValue
  renderFn(field.data)
}

// This works
function renderFieldWorks(field: FormField) {
  if (field.type === 'text') {
    const renderFn = fieldMapping[field.type]
    renderFn(field.data)
  } else if (field.type === 'select') {
    const renderFn = fieldMapping[field.type]
    renderFn(field.data)
  }
}
like image 658
nret Avatar asked Jun 26 '19 21:06

nret


2 Answers

I'm afraid that you're going to have to use a type assertion to avoid code duplication here. TypeScript's type system just doesn't have good support for these "correlated record types" or any manipulation that relies on the interaction of two union-typed values where the unions are not independent.

You've already arrived at the redundant-code-in-switch-statement workaround; here's the unsafe-assertion workaround:

function assertNarrowFunction<F extends (arg: any) => any>(f: F) {
  return f as (arg: Parameters<F>[0]) => ReturnType<F>; // assert
}

That takes a function of a union type like ((a: string)=>number) | ((a: number)=>boolean) and unsafely narrows it to a function which takes the union of its parameter types and returns the union of its return type, like ((a: string | number) => string | number). This is unsafe because a function of the former union type could be something like const f = Math.random()<0.5 ? ((a: string)=>a.length) : ((a: number)=>number.toFixed()), which definitely does not match ((a: string | number) => string | number). I can't safely call f(5) because maybe f is the string-length function.

Anyway, you can use this unsafe narrowing on renderFn to silence the error:

function renderFnAssertion(field: FormField) {
  const renderFn = assertNarrowFunction(fieldMapping[field.type]);
  renderFn(field.data); // okay
}

You've lied to the compiler a bit about the type of renderFn... not so much that it will accept any old argument (e.g., renderFn(123) will fail as desired), but enough that it would allow this:

function badRenderFn(field1: FormField, field2: FormField) {
  const renderFn1 = assertNarrowFunction(fieldMapping[field1.type]);
  renderFn1(field2.data); // no error!!! ooops
}

So you have to be careful.

Okay, hope that helps; good luck!

Link to code

like image 191
jcalz Avatar answered Oct 21 '22 09:10

jcalz


Possible Approach

Here it is in the playground.

A benefit of this approach is that if we delete all the type information, we are left with the original vanilla JavaScript from your question. The only downside that I see is the as to workaround the lack of correlated record types that jcalz noted. In this case it seems fine, because we do know more than the compiler knows, and we are not losing any type safety.

type TextFieldData = { value: string }
type TextField = { type: 'text', data: TextFieldData }
type SelectFieldData = { options: string[], selectedValue: string }
type SelectField = { type: 'select', data: SelectFieldData }
type FormField = TextField | SelectField

function renderTextField(props: TextFieldData) { }
function renderSelectField(props: SelectFieldData) { }

const fieldMapping = {
    text: renderTextField,
    select: renderSelectField,
}

// This is the new block of code.
type FindByType<Union, Type> = Union extends { type: Type } ? Union : never;
type TParam<Type> = FindByType<FormField, Type>['data'];
type TFunction<Type> = (props: TParam<Type>) => void;

function renderFieldDoesNotWork(field: FormField) {
    // This is the cast that seems unavoidable without correlated record types.
    const renderFn = fieldMapping[field.type] as TFunction<typeof field.type>;
    renderFn(field.data)
}

Additional Type Safety

In the code from your question, the compiler would not complain if a developer made a mistake in the mapping like this:

const fieldMappingOops = {
    select: renderTextField, // no compiler error
    text: renderTextField,
}

We can add a new type that would tell the compiler to complain in that situation:

type FieldMapping = {
    [Key in FormField['type']]: TFunction<Key>;
}

const fieldMappingOops: FieldMapping = {
    select: renderTextField, // compiler error
    text: renderTextField,
}

Additional Details

This GitHub comment by Ryan Cavanaugh inspired this approach. The FindByType gives us some of the type-narrowing power that we get from tagged union / discriminated union types when we use them with switch and if statement.

Here are how the types that are related to that comment expand when given relevant inputs:

// type f1 = {
//     type: "text";
//     data: TextFieldData;
// }
type f1 = FindByType<FormField, 'text'>;

// type f2 = {
//     value: string;
// }
type f2 = TParam<'text'>;

// type f3 = (props: TextFieldData) => void
type f3 = TFunction<'text'>;

// type f4 = TextField | SelectField
type f4 = FindByType<FormField, 'text' | 'select'>;

// type f5 = TextFieldData | SelectFieldData
type f5 = TParam<'text' | 'select'>;

// type f6 = (props: TextFieldData | SelectFieldData) => void
type f6 = TFunction<'text' | 'select'>;
like image 44
Shaun Luttin Avatar answered Oct 21 '22 10:10

Shaun Luttin