I can't figure out how to type this in Typescript.
I have a Javascript class that I'm porting, simplified here:
class A {
constructor ({
a = 'hi',
b = 5,
...rest
} = {}) {
this.a = a
this.b = b
this.extra = rest
}
}
The constructor takes an options object with some defaults and gathers the rest into an object. That rest params object should be flat, no nested objects or arrays and needs to be JSON serializable, and is completely optional. But if it is present I would like to provide the caller an opportunity to have it be well-typed. So I defined a couple of types:
type KnownStuff = {
a: string
b: number
}
type FlatJSONCompatibleObject = {
[k: string]: string | number | boolean | null
} | {}
In addition, I want to be able to specify some additional parameters for the rest:
type Test = {
c: boolean
d: 'a' | 'b'
}
So I tried the following:
class A <T extends FlatJSONCompatibleObject = {}> {
a: string
b: number
extra: T
constructor ({
a = 'hi',
b = 5,
...rest
}: Partial<KnownStuff> & T = {}) {
this.a = a
this.b = b
this.extra = rest // fails
}
}
Which AFAICT fails because the generic isn't constrained. So then next:
class Foo <T extends Omit<FlatJSONCompatibleObject, keyof KnownStuff>> {
a: string
b: number
extra: T
constructor ({
a = 'hi',
b = 5,
...rest
}: Partial<KnownStuff> & T = {}) {
this.a = a
this.b = b
this.extra = rest
}
}
but that fails to compile, I can't get the generic constraint correct.
Closest I've been able to come is this:
class Bar<T extends Omit<FlatJSONCompatibleObject, keyof KnownStuff>> {
a: string
b: number
extra: Omit<FlatJSONCompatibleObject, keyof KnownStuff>
constructor ({
a = 'hi',
b = 5,
}: Partial<KnownStuff> = {}, rest: Partial<T> = {}) {
this.a = a
this.b = b
this.extra = rest
}
}
Which compiles but changes the signature which I'm really trying to avoid since this is for work and already in widespread use by multiple other teams. It's also wrong:
const c = new Bar<Test>()
That should fail as the properties on the parameter are not optional, but I had to use Partial<T>
to be able to assign the default empty object. I suspect I'm barking up the completely wrong tree here, which is why I'm asking this question.
So the constraints of the problem:
- Class takes an options object with some known properties.
- Those known properties should all have defaults,
new Whatever()
should work. - Class can also take some additional properties in the object.
- Extra stuff if present must be JSON compatible and flat (no nested objects or arrays).
- User of the class should be able to e.g. pass a type parameter so that the extra stuff has a well-defined and compiler-enforced type, i.e.
new Whatever<SomeTypeWithRequiredProperties>()
should not work. - I really don't want to change (from a Javascript POV) the signature of the constructor since this is already in widespread use.
How do I type this?
and a (not correct but hopefully gives the gist) test harness of sorts:
// class test harness
function test<T extends FlatJSONCompatibleObject = {}>(C: {new <U extends FlatJSONCompatibleObject = {}>(opts?: Partial<KnownStuff> & U): LP<T>}) {
// should work
const t1 = new C();
t1.extras // should be '{}'
const t2 = new C<{c: boolean}>({c: true});
t2.extras.c // should be true
// should be compile error
const t3 = new C<{c: boolean}>();
const t4 = new C<{c: boolean}>({d: 4});
}
CodePudding user response:
I'm posting this as an answer (rather than an edit to the question) because it at least partially answers the question but I will not be accepting it because a. I don't entirely understand why it works and b. I could only make it work for functions and not a class constructor. I stumbled upon this partial solution thanks to some help from T.J. Crowder in the comments.
If one defines an interface like so:
type LP<T extends FlatJSONCompatibleObject> = {
a: string
b: number
extras: T
}
and then an overloaded function like so:
function bar(): LP<{}>
function bar<T extends FlatJSONCompatibleObject>(opts: Partial<KnownStuff> & T): LP<T>
function bar(opts?: Partial<KnownStuff>): LP<{}>
{
const {
a = 'hi',
b = 5,
...extras
} = opts || {};
if (opts) {
return {
a,
b,
extras
}
} else {
return {
a,
b,
extras: {}
}
}
}
const bar1 = bar();
bar1.extras // {}
const bar2 = bar<{c: boolean}>({c: true})
bar2.extras.c // boolean
const bar3 = bar<{c: boolean}>() // compile error
const bar4 = bar<{c: boolean}>({d: boolean}) // compile error
This works, but I can't make it work for a class:
class Foo <T extends FlatJSONCompatibleObject> {
public a: string
public b: number
// public extras: T | {}
public extras: T
constructor()
constructor(opts: Partial<KnownStuff> & T)
constructor(opts?: Partial<KnownStuff>)
{
const {
a = 'hi',
b = 5,
...extras
} = opts || {};
this.a = a
this.b = b
if (opts) {
this.extras = extras // error
} else {
this.extras = {} // error
}
}
}
Although I can get a little closer it seems by making the extras an explicit second parameter:
class Bar<T extends FlatJSONCompatibleObject> {
public a: string
public b: number
public extras: T
constructor()
constructor(opts: Partial<KnownStuff>, extras: T)
constructor(opts?: Partial<KnownStuff>, extras?: never)
{
const {
a = 'hi',
b = 5.
} = opts || {}
this.a = a
this.b = b
if (extras) {
this.extras = extras
} else {
this.extras = {} // error!
}
}
}
I still can't seem to get the correct overload.
CodePudding user response:
TL;DR
You can't do this with 100% type-safe TypeScript at the moment.
Details:
I'm still fairly early on my TS journey, but I think this may be a case of: Pick any 5 (of your 6). :-D I can do it where the argument is optional, but it doesn't enforce having an argument if you provide a type argument with required properties (#1 below). Or I can do it where you don't have the default argument (albeit with a relatively-innocent @ts-ignore
I would have rathered avoid) (#2 below). Or a slight twist on one of my earlier attempts gets really close (#3 below), but the type of extra
is {} | x
rather than just x
. I pinged Titian Cernicova Dragomir and he confirmed that you can't do all of this in today's TypeScript and provided #4 below, but like my #1 it also doesn't enforce the argument when you supply a type argument (also, the type of extra
ends up being Partial<something>
or Partial<{}>
).
The main problem is the one you flagged up: You can't assign {}
to a generically-typed property, because the concrete type of that generic might not allow {}
.
So sadly, it looks like it's going to be a matter of picking the closest thing.
#1
This is the one that doesn't handle new A<something>()
correctly:
type KnownStuff = {
a: string
b: number
}
type FlatJSONCompatibleObject = {
[k: string]: string | number | boolean | null
}
type Test = {
c: boolean
d: 'a' | 'b'
}
class A <Extra extends FlatJSONCompatibleObject = {}> {
a: string
b: number
extra: Extra
constructor ();
constructor (props: Partial<KnownStuff> & Extra);
constructor (props?: any) {
const {a = 'hi', b = 5, ...extra} = props ?? {}
this.a = a
this.b = b
this.extra = extra
}
}
const a1 = new A() // All good
a1.extra // Type is {}
const a2 = new A<{c: boolean}>({d: "hi"}) // Error as desired
a2.extra // Type is {c: boolean}
const a3 = new A<{c: boolean}>() // No error :-(
a3.extra // Type is {c: boolean}
const a4 = new A<{c: boolean}>({c: true}) // All good
a3.extra // Type is {c: boolean}
#2
This is the one that doesn't allow passing no argument to the constructor:
type KnownStuff = {
a: string
b: number
}
type FlatJSONCompatibleObject = {
[k: string]: string | number | boolean | null
}
type Test = {
c: boolean
d: 'a' | 'b'
}
class A <Extra extends FlatJSONCompatibleObject = {}> {
a: string
b: number
extra: Extra
constructor ({a = 'hi', b = 5, ...extra}: Partial<KnownStuff> & Extra) {
this.a = a
this.b = b
// @ts-ignore
this.extra = extra
}
}
// No longer relevant
// const a1 = new A()
// a1.extra // Type is {}
const a2 = new A<{c: boolean}>({d: "hi"}); // Error as desired
a2.extra // Type is {c: boolean}
const a3 = new A<{c: boolean}>(); // Error as desired
a3.extra // Type is {c: boolean}
const a4 = new A<{c: boolean}>({c: true}); // All good
a3.extra // Type is {c: boolean}
#3
This is the one where you have an awkward type for extra
, X | {}
rather than just X
:
type KnownStuff = {
a: string
b: number
}
type FlatJSONCompatibleObject = {
[k: string]: string | number | boolean | null
}
type Test = {
c: boolean
d: 'a' | 'b'
}
class A <Extra extends FlatJSONCompatibleObject = {}> {
a: string
b: number
extra: Extra | {}
constructor (props?: Partial<KnownStuff> & Extra) {
if (props) {
const {a = 'hi', b = 5, ...extra} = props
this.a = a
this.b = b
this.extra = extra
} else {
this.a = 'hi'
this.b = 5
this.extra = {}
}
}
}
const a1 = new A() // All good
a1.extra // Type is {}
const a2 = new A<{c: boolean}>({d: "hi"}) // Error as desired
a2.extra // Type is {c: boolean} | {}
const a3 = new A<{c: boolean}>() // Error as desired
a3.extra // Type is {c: boolean} | {}
const a4 = new A<{c: boolean}>({c: true}) // All good
a3.extra // Type is {c: boolean} | {}
#4
The one from Titian Cernicova Dragomir that has the new A<something>()
problem #1 has, and the type of extra
ends up being Partial<{}>
or Partial<something>
:
type KnownStuff = {
a: string
b: number
}
type FlatJSONCompatibleObject = {
[k: string]: string | number | boolean | null
} | {}
type Test = {
c: boolean
d: 'a' | 'b'
}
class A <T extends FlatJSONCompatibleObject = {}> {
a: string
b: number
extra: Partial<T>
constructor ({
a = 'hi',
b = 5,
...rest
}: Partial<KnownStuff> & Partial<T> = {}) {
this.a = a
this.b = b
this.extra = rest as Partial<T>
}
}
const a1 = new A() // All good
a1.extra // Type is Partial<{}>
const a2 = new A<{c: boolean}>({d: "hi"}) // Error as desired
a2.extra // Type is Partial<{c: boolean}>
const a3 = new A<{c: boolean}>() // No error :-(
a3.extra // Type is Partial<{c: boolean}>
const a4 = new A<{c: boolean}>({c: true}) // All good
a3.extra // Type is Partial<{c: boolean}>