Friday, January 05, 2024 · 4 min read
typescript

TypeScript Pattern 101: Basic Types and Interfaces

Mastering the Foundations of TypeScript Typing

Substack Banner TS101.png

Dive deeper than string and number and unveil the power of TypeScript’s type system through custom types, generics, and type manipulation. This is where the richness of TypeScript’s type system truly shines, opening doors to more robust and self-explanatory code.

The Building Blocks: Basic Types

I believe you’re familiar with boolean, string, number, and the like. But, a couple of nuances to bear in mind:

Use unknown as a safer alternative to any. It forces you to do type checking. never isn't just a theoretical type; it's super handy for exhaustive checks.

Interfaces: The Blueprints

Interfaces in TypeScript are like blueprints for objects. Sure, you can use types, but interfaces are more extensible and easier to read. Here’s how you use them to make function contracts:

interface AddFuncSignature {
    (a: number, b: number): number;
}

const add: AddFuncSignature = (x, y) => x + y;

Union and Intersection: Playing the Field

Unions | allow flexibility, while intersections & enforce that an entity conforms to multiple shapes.

type NetworkLoadingState = {
  state: 'loading';
};

type NetworkFailedState = {
  state: 'failed';
  code: number;
};

type NetworkSuccessState = {
  state: 'success';
  response: {
    title: string;
    duration: number;
  };
};

type NetworkState = 
  | NetworkLoadingState
  | NetworkFailedState
  | NetworkSuccessState;

For a more detailed discussion on discriminated unions, check out the Discriminated Unions section in my previous post about TypeScript Pattern 101: Type Guards.

Generics: One Size Doesn’t Fit All

Generics enable you to write code that retains type info throughout execution. A simple example using a generic function:

function firstElement<Type>(arr: Type[]): Type | null {
    return arr.length > 0 ? arr[0] : null;
}

const numbers = [1, 2, 3, 4];
const firstNumber = firstElement(numbers);  // 1

const strings = ["a", "b", "c"];
const firstString = firstElement(strings);  // "a"

Utility Types: Your Type Toolbox

You could write type transformations manually, or you could use utility types:

  • Partial<T> makes all properties in T optional.
  • Readonly<T> makes all properties in T read-only.
  • Pick<T, K> and Omit<T, K>: For fine-grained object shaping.

Advanced Type Manipulation

Conditional types:

Consider a type that extracts promise value types:

type ExtractPromiseTypeOrDefault<T> = T extends Promise<infer U> ? U : T;

async function fetchData() {
  const data: Promise<string> = new Promise((resolve) => resolve("hello world"));
  type DataValue = ExtractPromiseTypeOrDefault<typeof data>;  // Type is string

  const nonPromiseValue: number = 42;
  type NonPromiseValueType = ExtractPromiseTypeOrDefault<typeof nonPromiseValue>;  // Type is number
}

Here, ExtractPromiseTypeOrDefault extracts the value type that a promise resolves to. This can be very useful when dealing with asynchronous operations.

Mapped types:

Suppose you’re building a feature flag system to enable or disable certain functionalities in your app.

type FeatureFlags = 'NewDashboard' | 'BetaTesting' | 'DarkMode';
type FeatureConfig = { [K in FeatureFlags]: boolean };

const featureConfig: FeatureConfig = {
  NewDashboard: true,
  BetaTesting: false,
  DarkMode: true,
};

function isFeatureEnabled(flag: FeatureFlags): boolean {
  return featureConfig[flag];
}

const isNewDashboardEnabled = isFeatureEnabled('NewDashboard');  // true

Now you have a FeatureConfig type that maps directly to the state of your features. The isFeatureEnabled function demonstrates how you might use this mapped type to check the status of a particular feature.

Template literal types:

type EntityType = 'user' | 'post' | 'comment';
type Endpoint = `/${EntityType}/:id`;

function getEndpoint<Entity extends EntityType>(entity: Entity, id: string): Endpoint {
  return `/${entity}/${id}` as Endpoint;
}

const userEndpoint = getEndpoint('user', '123');  // Outputs "/user/123"

In this section, the Endpoint type and getEndpoint function clearly illustrate the utility of template literal types in constructing URL endpoints in a type-safe manner. Through the example, it's demonstrated how you might utilize template literal types alongside generics to ensure type safety while achieving the desired string output.

The Dark Side of Loose Typing

Using loose types like any in TypeScript may seem convenient, but it comes with risks, especially in complex systems. It makes your code harder to debug and maintain, undoing some of the benefits of using TypeScript in the first place.

Closing Thoughts

Having discussed about custom types, generics, and advanced type manipulation, we’ve just scratched the surface of what’s possible. The power of TypeScript lies not just in its ability to catch errors at compile time, but in its capability to model the shape of the data within our applications effectively. As we’ve seen, this leads to more self-explanatory, robust, and maintainable code. The examples provided are stepping stones; the real magic happens when you apply these concepts to solve complex problems in your projects. So, take these patterns, extend them, and see how they can elevate your code to a new level of clarity and reliability. Stay tuned for our next topic; catch you then!