8

I am modeling an API request format in TypeScript:

interface ApiTerm {
    term: {
        name: string,
        value: string,
    }
}

interface ApiAnd {
    and: {
        lhs: ApiItem,
        rhs: ApiItem,
    }
}

interface ApiOr {
    or: {
        lhs: ApiItem,
        rhs: ApiItem,
    }
}

type ApiItem =
    | ApiTerm
    | ApiAnd
    | ApiOr
    ;

This works, but I will need to implement many binary operations beyond just "and" and "or" so I'd like some way to shorten and reuse the code.

Following some other code I've written that uses a specific string enum value in an interface, I tried to use a string enum as the property name:

enum Operation {
    And = "and",
    Or = "or",
}

interface ApiBinary<O extends Operation> {
    [O]: {
        lhs: ApiItem,
        rhs: ApiItem,
    }
}

type ApiItem =
    | ApiTerm
    | ApiBinary<Operation.And>
    | ApiBinary<Operation.Or>
    ;

Unfortunately, this generates an error with TypeScript 2.9.1:

A computed property name in an interface must refer to an expression whose type is a literal type or a 'unique symbol' type.

Is there an alternative solution that will allow me to avoid having to write out the numerous duplicate interfaces that will only differ by the name of the key?

Looking into the "a 'unique symbol' type" part of the error message, I don't believe I can create an enum based on Symbols.

Related questions

Use string enum value in TypeScript interface as a computed property key seems very close, but is about using specific string enum values in an interface, which works as-in in newer TypeScript versions.

Shepmaster
  • 388,571
  • 95
  • 1,107
  • 1,366

1 Answers1

5

Instead of using a computed property, you could use a mapped type:

interface ApiTerm {
    term: {
        name: string,
        value: string,
    }
}

enum Operation {
    And = "and",
    Or = "or",
}

type ApiBinary<O extends Operation> = {[o in O]: {
        lhs: ApiItem,
        rhs: ApiItem,
    }
}

type ApiItem =
    | ApiTerm
    | ApiBinary<Operation.And>
    | ApiBinary<Operation.Or>
    ;

const andExample: ApiBinary<Operation.And> = {
    'and': {
        lhs: { term: { name: 'a', value: 'b' } },
        rhs: { term: { name: 'c', value: 'd' } }
    }
}

However, note that there is no way to express the restriction that ApiBinary can have only one property; for example, someone could declare type N = ApiBinary<Operation.And | Operation.Or>;

When you use a mapped type, it does not mix well with computed properties - the compiler can't infer that the computed property type conforms to the constraint in ApiItem in this example:

const foo = ({ kind, lhs, rhs }: { kind: Operation, lhs: ApiItem, rhs: ApiItem }): ApiItem =>
({ [kind]: { lhs, rhs } });

The error says is not assignable to type 'ApiBinary<Operation.Or>' because the compiler tries to check assignability to all union members and if it fails it only tells about the last one.

The only way I can think of writing this function is long-winded:

const foo = <O extends Operation>({ kind, lhs, rhs }: { kind: O, lhs: ApiItem, rhs: ApiItem }) => {
    const result = {} as ApiBinary<O>;
    result[kind] = {lhs, rhs};
    return result;
}
Shepmaster
  • 388,571
  • 95
  • 1,107
  • 1,366
artem
  • 46,476
  • 8
  • 74
  • 78
  • Thank you! Congratulations on reaching 10k! – Shepmaster Jun 14 '18 at 13:06
  • I see that this works with a literal value, [but I'm encountering an issue when trying to construct a value in a function](https://agentcooper.github.io/typescript-play/#code/JYOwLgpgTgZghgYwgAgIIAdgBVoFtkDeAUMqcpFLgFyEln0hy4Q0DOYUoA5gDR32kAbnAA2AVxbJ2nEL37IAvkSVEIIMfgDy6aHDDAA9iFr1UIACbIAvMgBEcC7b71NUa3YNQnyokTABPHTRMACFQOCh-AB5NZAgAD0gLVmRtXX0jAD53YnoAbQNkUFSAXRpcgVIRAAtWGgxgAElIXGdK5Cha+sxmiFb5JRUAoIbe-Ct5AB9g7DwpmbDGSJidKD1DEAA6M3NM+YbFiOi0tYyt1z36AG5fBCN2ZBgDQpsACgJkAGtQcx5kGtYf06KQU5S+PxoJ3WRj+AO6TRaQK6MzGigAlPDUVZLmR3sg8t8LGVCP9akiQeibkA): `Property 'or' is missing in type` – Shepmaster Jun 14 '18 at 14:25
  • Is that part of this Q&A or should I open a new one? – Shepmaster Jun 14 '18 at 14:25
  • Cool. Is there any difference between that and `return { result[kind]: { lhs, rhs } } as ApiBinary;`? Is it basically just the type cast that is causing it to work? – Shepmaster Jun 14 '18 at 14:56
  • It's `return { [kind]: { lhs, rhs } };` I think, yes you need just the type cast and your variant gives the same result and is shorter, I'm just not used to computed properties so didn't think about it – artem Jun 14 '18 at 15:21
  • Oops, yes, that's what I get for writing code in the comment box. Thank you again. – Shepmaster Jun 14 '18 at 15:22