Chris Drackett
Chris Drackett

Reputation: 1327

Is it possible to infer an object type based on a passed in object?

I have various documents that all share a set of common fields.

I'm trying to create a function that will populate the common fields while taking the unique fields as an input, resulting in a complete document.

I have this working, but right now I need to manually specify the final type. I'm curious if there is a way in typescript to infer the final type based on the fields that are passed in (or throw an error if the passed in fields don't match any of the known doc types.

Here is the code I have right now for this:

type DocA = {
  id: string,
  createdAt: string,
  onlyDocA: string
}

type DocB = {
  id: string,
  createdAt: string,
  onlyDocB: string
}

type AllDocTypes = DocA | DocB

export const createBaseDocument = <DocType extends AllDocTypes>(
  fields: Omit<DocType, 'id' | 'createdAt'>,
): DocType => {
  const createdAt = new Date();

  return {
    id: generateId(),
    createdAt: createdAt.toString(),
    ...fields,
  } as DocType
}

function generateId(): string {
  return 'id'
}

// testA should be of type `DocA`
const testA = createBaseDocument({ id: 'a', createdAt: '123', onlyDocA: 'hello'})

// testA should be of type `DocB`
const testB = createBaseDocument({ id: 'a', createdAt: '123', onlyDocB: 'hello'})

// testC should throw a type error
const testC = createBaseDocument({ id: 'a', createdAt: '123', onlyDocC: 'hello'})

// testD should throw a type error
const testD = createBaseDocument({ id: 'a', createdAt: '123', onlyDocA: 'hello', onlyDocB: 'hello'})

typescript playgroud

Upvotes: 3

Views: 91

Answers (3)

Lazar Ljubenović
Lazar Ljubenović

Reputation: 19764

Slightly reversing the logic you had makes it work. Unfortunately, I don't know exactly why your way fails (but Oleg Valter does).

interface DocBase {
  id: string
  createdAt: string
}

interface OnlyDocA {
  onlyDocA: string
}

interface OnlyDocB {
  onlyDocB: string
}

type Onlys = OnlyDocA | OnlyDocB

function createBaseDocument<T extends Onlys> (fields: T): DocBase & T {
  const createdAt = new Date();

  return {
    id: generateId(),
    createdAt: createdAt.toString(),
    ...fields,
  }
}

function generateId(): string {
  return 'id'
}

const x = createBaseDocument({ onlyDocA: 'a' })

Playground

It also disallows passing in random stuff like {onlyDocX: 1} unless you've defined it previously.

Upvotes: 2

0Valt
0Valt

Reputation: 10365

To extend Lazar's answer with an explanation as to why:

Your approach fails because Omit<AllDocTypes, "id"|"createdAt"> does not do what you expect it to do. You want to use it leave onlyDocA or onlyDocB properties, but this is not how this works. What you actually get is an empty object {}:

type testOmit = Omit<AllDocTypes, "id"|"createdAt">; // {}

The {} type is actually a very wide type in this context and means "an object with any number of properties of any type". This is why you can specify whatever in the function call.

As to why this is happening, this is because you use it with a generic parameter type with a union. The compiler doesn't know what concrete type is substituted as an argument, hence the Omit helper works with the union of DocA | DocB.

As you probably know, only shared properties can be accessed on unions of objects (doing otherwise would not be safe), therefore, after removing the "id" and "createdAt" shared properties, you are left with an empty object (onlyDocA and onlyDocB are not shared).


As to why intersecting generic with the DocBase (<T extends Onlys> (fields: T): DocBase & T) does not prevent the addition of excess properties, this is because in the absence of an explicit parameter annotation, T is inferred from the argument passed in, for example:

/**
function createBaseDocument<{
    onlyDocA: string;
    whatever: string;
}>(fields: {
    onlyDocA: string;
    whatever: string;
}): DocBase & {
    onlyDocA: string;
    whatever: string;
}*/
const z = createBaseDocument({ onlyDocA: "a", whatever: "whenever" }); // ok

For the solution to work you have to be explicit:

const x = createBaseDocument<OnlyDocA>({ onlyDocA: 'a' }); //ok
const y = createBaseDocument<OnlyDocA>({ onlyDocA: 'a', onlyDocB: 'b' ,onlyDocX: 'x' }); //error

Playground

Upvotes: 2

Nadia Chibrikova
Nadia Chibrikova

Reputation: 5036

If you want to have better control over what goes into the function, you might want to use function overload instead:

type DocA = {
  id: string,
  createdAt: string,
  onlyDocA: string
}

type DocB = {
  id: string,
  createdAt: string,
  onlyDocB: string
}

function createBaseDocument(fields: Omit<DocA, 'id'| 'createdAt'>): DocA;
function createBaseDocument(fields: Omit<DocB, 'id'| 'createdAt'>): DocB;
function createBaseDocument(fields: Omit<DocA, 'id'| 'createdAt'>|Omit<DocB, 'id'| 'createdAt'>): DocB|DocA{
  const createdAt = new Date();
  return {
    id:generateId(),
    createdAt: createdAt.toString(),
    ...fields
  }
}

This way createBaseDocument({onlyDocA:'a'}) and createBaseDocument({onlyDocB:'b'}) will be fine, but createBaseDocument({onlyDocA:'a',onlyDocB:'b'}) will result in an error

Upvotes: 2

Related Questions