How to add multiple assertions in Typescript

Viewed 610

I am trying the below code and not sure if I could tell typescript to assume the type from either of the interfaces

export interface CellA {
    text: string;
}

export interface CellB {
    date: Date;
}

var cell = {};

const { date, text } = cell as (CellA | CellB);

console.log(date, text);

TS Error: Property 'date' does not exist on type 'CellA | CellB'.(2339)

What I am after is to get typescript to assume the destructed variables exist in either of the interfaces. TS Playground Example

4 Answers

The way you're doing it is making the assumption that cell has both date and text on it. But then when you assign cell as either a CellA or CellB type, typescript will complain because each type is missing one of those properties.

Can you just assign optional properties? Like this:

interface CellType {
  date?: Date,
  text?: string
}

const { date, text } = cell as CellType

Or if you really want to force a cell to strictly be one of these types, I would do it when you define the variable:

interface CellA {
  text: string;
}

interface CellB {
  date: Date;
}

type CellType = CellA | CellB

const cell: CellType = { ... }

Your issue is that cell can be of type CellA which doesn't have the property date (hence the error message you are getting). Note that the vice versa is also true, that cell doesn't have the property text either.

Potential Solutions

1. Use an intersection type

If cell is supposed be a combination of both CellA and CellB then you can make cell of type CellA & CellB. For example:

type CellC = CellA & CellB

const cell: CellC = { date: new Date(), text: 'Hello!' }

const { date, text }
console.log(date, text)

2. Use type guards: Playground

If you have a value that you need to type check at runtime, you could use a user-defined type guard. For example, if you have a variable cell whose type you don't know at runtime:

const cell = { text: 'Hello!' } // unknown type

function isCellA(obj: any): obj is CellA {
    if(!obj) return false
    if(!obj.text) return false
    if(typeof obj.text !== 'string') return false

    return true
}

if(isCellA(cell)) {
    // at this point the compiler recognizes that cell
    // is of type CellA
    console.log(cell.text)
}

If you need to do type checking at runtime, this is probably your best choice. It maintains type safety and helps reduce runtime errors if cell really isn't what you expected it to be.

3. Extend another interface

This one is pretty similar to solution #1. If CellA and CellB are supposed to share some properties then one of them can extend the other, or they can extend a common interface. For example:

interface Cell {
    date: Date
}

interface CellC extends Cell {
    text: string
}

4: React component composition

If you are having this issue specifically React (as your comment states), you could consider having a separate component that returns the main component for each cell type. Example:

function ACell({ data }: { data: CellA } ) {
    return <Cell>{data.text}<Cell>
}

function BCell({ data }: { data: CellB } ) {
    return <Cell>{data.date.toString()}<Cell>
}

function Cell({ children }: { children: string }) {
    return <p>{children}</p>
}

Maybe that doesn't meet your specific use case, but something similar could be helpful in separating logic for each type of cell between components, and sharing common logic with a third Cell component, for example.

If it's possible for you, you can extend the interfaces to accept any other value. For example:

export interface CellA {
    text?: string;
    [index: string]: any;
}

export interface CellB {
    date?: Date;
    [index: string]: any;
}

Generally where you want to switch your control flow on the type of an argument/variable in Typescript you'll want to use a discriminated union:

interface CellA {
  text: string,
  kind: "CellA",
}

interface CellB {
  date: Date,
  kind: "CellB",
}

type Cell = CellA | CellB;

function takesACell(cell: Cell) {
  switch(cell.kind) {
    case "CellA":
      console.log("Got an A");
      cell.text; // no type error
      break;
    case "CellB":
      console.log("Got a B");
      cell.date; // no type error
      break;
  }
}

The compiler understands this pattern to mean that you are dispatching based on the runtime type but it will still provide compile-time type safety in that you can't just pass a non-conforming value.

Playground

Related