2

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});
}
Jared Smith
  • 19,721
  • 5
  • 45
  • 83
  • 1
    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: https://tsplay.dev/wXkM8W 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): https://tsplay.dev/wO8yyN But I don't think you're going to be able to specify a default value for the argument *and* have that argument with a generic type, because I'm told that... – T.J. Crowder Sep 20 '21 at 15:17
  • 1
    ...you can't assign concrete values to members with more-constrained generic types (which I think, from the question, you already knew); [more here](https://stackoverflow.com/questions/56505560/). I hope I'm wrong that you can't have all 6! :-) – T.J. Crowder Sep 20 '21 at 15:17
  • 1
    @T.J.Crowder well, that's certainly helpful and I do appreciate it. I'm also still really hoping someone has a clever complete solution. – Jared Smith Sep 20 '21 at 15:27
  • 1
    @T.J.Crowder see my self-answer. Got a fair bit closer thanks to your help – Jared Smith Sep 21 '21 at 13:12

2 Answers2

1

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

Jared Smith
  • 19,721
  • 5
  • 45
  • 83
  • I think the reason it works with a standalone function but not with the class is that the type of `extra` can be different in the standalone function return type, but the class can only have a single type for `extra`. A slight twist on one of my earlier attempts ([here](https://tsplay.dev/m0LrPm)), gets **really** close, but the type of `extra` is `{} | x` rather than just `x`. I feel like [conditional types](https://www.typescriptlang.org/docs/handbook/2/conditional-types.html) should be able to help here, but... – T.J. Crowder Sep 21 '21 at 13:41
  • 1
    @T.J.Crowder yes, I think that's the difference too (the extra layer of indirection in the function version). And conditional types were one of my first thoughts as well, but I can't think of a way to use them here. It's really close, I can't help but feel you and I are missing *something* that would make this work. – Jared Smith Sep 21 '21 at 13:47
  • ...I think I could figure it out if I understood better why that **exact** sequence of overloads does it, but I stumbled into it somewhat blindly. – Jared Smith Sep 21 '21 at 13:48
  • 1
    FWIW, I pinged Titian Cernicova Dragomir about this on Twitter. He confirmed you just can't do this with 100% type-safe TypeScript. He sent [this](https://tsplay.dev/N7grPN) but note that like one of my attempts, it doesn't error on `new A()`. – T.J. Crowder Sep 21 '21 at 14:00
  • 1
    @T.J.Crowder I'll take that as definitive that it can't be done (at least using today's Typescript). Thanks again for your help on this. If you care to post that as an answer I'll upvote and accept. – Jared Smith Sep 21 '21 at 14:06
  • 1
    I've collected my various comments and Titian's solution and posted a CW answer. Feel free to fold your approach above in as well if you like. And hey, we can't say we didn't try! :-) Happy coding! – T.J. Crowder Sep 21 '21 at 14:29
  • 1
    @T.J.Crowder thanks again for everything. – Jared Smith Sep 21 '21 at 17:49
1

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}>
T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875