Typescript: Refer directly to string literal properties on a type

Created on 6 Oct 2016  路  8Comments  路  Source: microsoft/TypeScript

When using TypeScript with React + Redux, it is a common pattern to use string literals as discriminators on Action types:

interface Action {
    type: string
}

interface Increment extends Action {
    type: 'INCREMENT',
    incBy: number
}

It would be nice if there were a way to refer directly to the type property and have it inlined, so that for example the following code could be written

type Reduction<TState, TAction> = (state:TState, action: TAction) => TState

const reducer = <TState, TAction extends Action>(reduction: Reduction<TState, TAction>) =>
    (state: TState, action: Action) =>
        isAction<TAction>(action)
        ? reduction(state, action)
        : state

const isAction = <TAction extends Action>(a: Action): a is TAction =>
    a.type === TAction.type // cannot find name 'TAction'

// usage:
const incrementReducer = reducer((s: number, a: Increment) => s + a.incBy)

Currently the only way to do this is to duplicate the string literal, like so:

type Reduction<TState, TAction> = (state:TState, action: TAction) => TState

const reducer = <TState, TAction extends Action>(type: string, reduction: Reduction<TState, TAction>) =>
    (state: TState, action: Action) =>
        isAction<TAction>(type, action)
        ? reduction(state, action)
        : state

const isAction = <TAction extends Action>(type: string, a: Action): a is TAction =>
    a.type === type

// usage:
const incrementReducer = reducer('INCREMENT', (s: number, a: Increment) => s + a.incBy)

I feel like using duplicated strings everywhere is unnecessary boilerplate and bound to cause issues with copy/pasting and typos.

I guess this would mean there would need to be a way to declare an interface as having string literal/compile-time constant properties, otherwise it would not be possible to tell at compile time that TAction.type is a string literal.

Most helpful comment

There is no runtime representation of TAction.type. Types are compile time only. Also, what happens when type is a union?

interface Increment extends Action {
    type: 'INCREMENT'|'DECREMENT',
    incBy: number
}

All 8 comments

There is no runtime representation of TAction.type. Types are compile time only. Also, what happens when type is a union?

interface Increment extends Action {
    type: 'INCREMENT'|'DECREMENT',
    incBy: number
}

You're right, sorry... I was thinking that generic functions were compiled per-implementation-type but obviously they're just compiled to a single JS function! I guess this can't be done then, that's a shame

Actually, I've had a thought...

I think the generic type constraint should not be an interface but something denoting the string literal discriminant. So the syntax could be something like:

const isAction = <TAction extends string .type>(a: Action): a is TAction =>
    a.type === TAction.type

At the moment the isAction function is generated like so:

var isAction = function (a) {
    return a.type === TAction.type;
};

However, if it were generated like this:

var isAction$type = function(type) {
    return function(a) {
        return a.type === TAction.type;
    }
}

... then this TypeScript call:

isAction<Increment>(myAction)

... could be generated like this:

isAction('INCREMENT')(myAction);

Perhaps that's a lot of work and complexity for little gain, though - I'm no compiler writer...

Would this work?

class Action {
    static readonly  type: string = '';
}
interface Action {
    readonly type: string
}
class Increment {
    static readonly type = 'INCREMENT';
}
interface Increment extends Action {
    incBy: number
}
class Decrement {
    static readonly type = 'DECREMENT';
}
interface Decrement extends Action {
    dcrBy: number
}

const isAction = <TAction extends Action>(a: Action, is: TAction): a is TAction =>
    a.type === is.type;

@aluanhaddad this just pushes the string instance out to a property on an object: the object must still be passed in as an argument, in which case the string may as well be passed.

In addition, for the redux use case, Actions must be 'pure' objects (eg not classes)

You can use enum values as interface literals.

enum ActionType { 
  INCREMENT,
  DECREMENT
}

interface Increment {
  type:  ActionType.INCREMENT;
  incBy: number;
}

interface Decrement {
  type:  ActionType.DECREMENT;
  decBy: number;
}

type Action = Increment | Decrement;

You still have to pass the parameter, but it's type safe and you get auto-complete help.

It seems like string enums + the solution posted by @icholy is what you're looking for. Thoughts?

Yes, this approach will work. Thanks!

Was this page helpful?
0 / 5 - 0 ratings