Overriding Properties when Extending Objects
typescript-today

Overriding Properties when Extending Objects

Limitations on extending/intersecting objects in Typescript

The Problem

Try this in your editor:

typescript
1interface IBaseModel {
2	id: string;
3	created: Date;
4	updated: Date;
5}
6interface IStudent extends IBaseModel {
7	id: number; //override type
8	name: string;
9}

[View in Playground]
in case you don't want to type it yourself :-)

You get an error with the second interface declaration (Student):

Interface Student incorrectly extends interface IBaseModel. Types of property id are incompatible. Type number is not assignable to type string.

In other words, "you cannot change the type of a property in the interface from which you are extending your new interface."

A harder problem to debug...

Now try this in your editor:

typescript
1type StudentBase = {
2  kind: "StudentBase";
3  name: string;
4}
5
6type Student = StudentBase & {
7  kind: "Student";
8  email: string;
9}
10
11function printStudent(student: Student) {
12    console.log(student);
13}
14
15printStudent({
16    kind: "Student",
17    name: "Roger",
18    email: "roger@school.org"
19});

[Playground]

This time, you get errors on the three properties (kind, name, and email) in the Student argument you passed into printStudent(). The text of each error is "Type 'string' is not assignable to type never".

What is going on here? We can see that each of the properties has a string type.

The issue is when we intersect two objects, (e.g. A & B ) we end up with an object that has all of the properties of A and all of the properties of B. In our example above, the resulting type for Student would be...

typescript
1type Student = {
2	//the stuff from StudentBase
3	kind: "StudentBase";
4	name: string;
5	//the stuff added
6	kind: "Student";
7	email: string;
8}

As you can see, we have two definitions for the property kind and it is impossible for its value to be "StudentBase" and "Student" at the same time. Thus, Student type is never: no value can ever satisfy its definition.

An easier way of looking at this is:
type Fancy = string & number;
Can you think of any value that is both a string and a number? "14" !== 14

Solution

So how can we extend interfaces and intersect types that have properties in common?

Answer: simple - just omit the common property from the interface/type that you want to override.

typescript
1interface IBaseModel {
2	id: string;
3	created: Date;
4	updated: Date;
5}
6interface IStudent extends Omit<IBaseModel, 'id'> {
7	id: number; //override type
8	name: string;
9}
10
11type StudentBase = {
12  kind: "StudentBase";
13  name: string;
14}
15
16type Student = Omit<StudentBase, 'kind'> & {
17  kind: "Student";
18  email: string;
19}
20
21function printStudent(student: Student) {
22    console.log(student);
23}
24
25printStudent({
26    kind: "Student",
27    name: "Roger",
28    email: "roger@school.org"
29});

[Playground]

Follow up

Recently, I came across this SO answer to the question "How to override type properties in Typescript". The author suggested creating a helper type:

typescript
1type Overwrite<T, U> = Pick<T, Exclude<keyof T, keyof U>> & U;


Let's break it down. Essentially, the helper type returns the intersection of two types, but the everything in the first type except those properties that are in the second type.

Pick<T, (these)> ... Pick these properties of T (the first type)

Exclude<keyof T, keyof U> ... Exclude<A, B> takes two union types (A and B) and returns all union members of A not in B.

Example: Exclude<'a'|'b'|'c', 'c'|'d'> results in 'a'|'b'
Adding in the keyof

typescript
1type Obj1 = { a: string, b: string, c: string };
2type Obj2 = { c: number, d: number };
3type Test2 = Exclude<keyof Obj1, keyof Obj2>;
4//    ^? Test2 = "a" | "b"

Combining it with Pick<T, (these)>

typescript
1type Obj1 = { a: string, b: string, c: string };
2type Obj2 = { c: number, d: number };
3type Test3 = Pick<Obj1, Exclude<keyof Obj1, keyof Obj2>>
4//    ^? Test3 = { a: string, b: string }

And throw in the intersection...

typescript
1type Obj1 = { a: string, b: string, c: string };
2type Obj2 = { c: number, d: number };
3type Test4 = Pick<Obj1, Exclude<keyof Obj1, keyof Obj2>> & Obj2;
4//    ^? Test4 = { a: string, b: string, c: number, d: number }

[Playground for all of these examples]

Takes time to sink in...

My initial reaction was "very cool, but my Omit<> strategy works just fine." In fact, it wasn't until I reviewed the SO answer for this blog post that I began to realize just how useful the helper really is.

First off, using Overwrite<T, U> has the advantage that you do not have to list the properties that you want to Omit<>. It automatically uses the common properties from the second object type (U).

Second, it is more intuitive than using the intercept operator (`&`) - at least for me.

I have changed its name to Override<T, U> but that might change as I continue to ponder this find approach to intersecting two object types with common property names.

popularity
On Fire
posted
Mar. 08, 2024