typescripttypessum-type

How to construct a type out of partial in Typescript?


I have a base interface in which extended into several interfaces.

interface BaseEvent {
    user_id: string,
    workspace_id: string,
    created_at: Date,
}

interface LoginEvent extends BaseEvent {
    ipaddr: string,
}

interface SignupEvent extends BaseEvent {
    email: string,
}

To construct object with above interface with less typing, I want to create a helper constructor (or factory), with some field already filled.

export function create<T extends BaseEvent>(ev: Omit<T, keyof BaseEvent>): T {
  let o: BaseEvent = {
    user_id: 'u001',
    workspace_id: 'w001',
    created_at: new Date,
  };

  return Object.assign(ev, o);
}

// Usage
let ev: LoginEvent = create<LoginEvent>({ ipaddr: '127.0.0.1' });

But the above code get some error:

Type 'Omit<T, keyof BaseEvent> & BaseEvent' is not assignable to type 'T'.

which is not true.

Here's the playground link.

Why does Omit<T, keyof BaseEvent> & BaseEvent not the same as T?


From the comments, the Omit<T, keyof BaseEvent> & BaseEvent is not the same as T because T may have more per-key constraint that may lost when the key is a duplicate of the Omit-ed one.

At this point, I just assumed that Typescript is not smart enough to detect the resulting type when Omit is used given when T always override the BaseEvent key in the result is prioritized. Given this playground: Since ev (T) always override its parental type o (BaseEvent), there is no worry of more restrictive key. Because the result Object.assign(o, ev) will satisfy both T and extends BaseEvent.

Why I need to return T? This is because I have other function that receive T as its parameter. Returning more raw Omit & BaseEvent will require a casting to T which is a walk-around way.

type Event = SignupEvent | LoginEvent;
function log(ev: Event) {
    // Log the object
}

// Usage:
log(create<LoginEvent>({ ipaddr: '127.0.0.1' }));
// rather than unpractical
log(create<LoginEvent>({ ipaddr: '127.0.0.1' }) as LoginEvent);

If so, how to express this guarantee in Typescript?


Solution

  • Unfortunately Typescript has its own limitations and quirks. In such cases very often you just need to unsafely force a proper type or introduce some function call just to resolve the underlying issue and make the compiler happy. In your case it could be just: return Object.assign(ev, o) as T;.

    But actually as @jcalz shown there is an edge case scenario resulting in a potential runtime error. Below is an alternative approach for supporting your use case. It differs with respect to yours:

    These differences could be seen as advantages. Moreover the set of allowed events could be closed to prevent defining events in unrelated files / modules.

    interface BaseEvent {
       user_id: string,
       workspace_id: string,
       created_at: Date,
    }
    
    interface EventsParams {
       Login: { ipaddr: string }
       Signup: { email: string }
       // MySpecial: { user_id: 'mySpecialId'}
    }
    
    type EventName = keyof EventsParams
    
    type Events = { [K in keyof EventsParams]: EventsParams[K] & BaseEvent }
    
    
    function create<T extends EventName>(ev: EventsParams[T]): Events[T] {
       let o: BaseEvent = {
          user_id: '001',
          workspace_id: '/some/path',
          created_at: new Date,
       };
    
       return Object.assign(ev, o) as Events[T];
    }
    
    
    interface EventsParams {
       MySpecial: { user_id: 'mySpecialId'}
    }
    
    let ev = create<'MySpecial'>({ ipaddr: '127.0.0.1' }); // NOT ALLOWED
    let ev2 = create<'?'>({ ipaddr: '127.0.0.1' }); // ERROR Unknown name/event
    

    PLAYGROUND