Home > Enterprise >  How to type constructor option object argument with strongly typed rest params
How to type constructor option object argument with strongly typed rest params

Time:09-22

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:

  1. Class takes an options object with some known properties.
  2. Those known properties should all have defaults, new Whatever() should work.
  3. Class can also take some additional properties in the object.
  4. Extra stuff if present must be JSON compatible and flat (no nested objects or arrays).
  5. 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.
  6. 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?

Playground if it helps

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.

Playground

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:

Playground link

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:

Playground link

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:

Playground link

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>:

Playground link

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}>
  • Related