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?
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:
EventInterface
to create an event; instead you use event's nameEvents
)BaseEvent
's propertyThese 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