TypeScript

History with GitLab

TypeScript has been considered, discussed, promoted, and rejected for years at GitLab. The general conclusion is that we are unable to integrate TypeScript into the main project because the costs outweigh the benefits.

  • The main project has a lot of pre-existing code that is not strongly typed.
  • The main contributors to the main project are not all familiar with TypeScript.

Apart from the main project, TypeScript has been profitably employed in a handful of satellite projects.

Projects using TypeScript

The following GitLab projects use TypeScript:

Recommendations

Setup ESLint and TypeScript configuration

When setting up a new TypeScript project, configure strict type-safety rules for ESLint and TypeScript. This ensures that the project remains as type-safe as possible.

The GitLab Workflow Extension project is a good model for a TypeScript project’s boilerplate and configuration. Consider copying the tsconfig.json and .eslintrc.json from there.

For tsconfig.json:

  • Use "strict": true. This enforces the strongest type-checking capabilities in the project and prohibits overriding type-safety.
  • Use "skipLibCheck": true. This improves compile time by only checking references .d.ts files as opposed to all .d.ts files in node_modules.

For .eslintrc.json (or .eslintrc.js):

Avoid any

Avoid any at all costs. This should already be configured in the project’s linter, but it’s worth calling out here.

Developers commonly resort to any when dealing with data structures that cross domain boundaries, such as handling HTTP responses or interacting with untyped libraries. This appears convenient at first. However, opting for a well-defined type (or using unknown and employing type narrowing through predicates) carries substantial benefits.

// Bad :(
function handleMessage(data: any) {
  console.log("We don't know what data is. This could blow up!", data.special.stuff);
}

// Good :)
function handleMessage(data: unknown) {
  console.log("Sometimes it's okay that it remains unknown.", JSON.stringify(data));
}

// Also good :)
function isFooMessage(data: unknown): data is { foo: string } {
  return typeof data === 'object' && data && 'foo' in data;
}

function handleMessage(data: unknown) {
  if (isFooMessage(data)) {
    console.log("We know it's a foo now. This is safe!", data.foo);
  }
}

Avoid casting with <> or as

Avoid casting with <> or as as much as possible.

Type casting explicitly circumvents type-safety. Consider using type predicates.

// Bad :(
function handler(data: unknown) {
  console.log((data as StuffContainer).stuff);
}

// Good :)
function hasStuff(data: unknown): data is StuffContainer {
  if (data && typeof data === 'object') {
    return 'stuff' in data;
  }

  return false;
}

function handler(data: unknown) {
  if (hasStuff(data)) {
    // No casting needed :)
    console.log(data.stuff);
  }
  throw new Error('Expected data to have stuff. Catastrophic consequences might follow...');
}

There’s some rare cases this might be acceptable (consider this test utility). However, 99% of the time, there’s a better way.

Prefer interface over type for new structures

Prefer declaring a new interface over declaring a new type alias when defining new structures.

Interfaces and type aliases have a lot of cross-over, but only interfaces can be used with the implements keyword. A class is not able to implement a type (only an interface), so using type would restrict the usability of the structure.

// Bad :(
type Fooer = {
  foo: () => string;
}

// Good :)
interface Fooer {
  foo: () => string;
}

From the TypeScript guide:

If you would like a heuristic, use interface until you need to use features from type.

Use type to define aliases for existing types

Use type to define aliases for existing types, classes or interfaces. Use the TypeScript Utility Types to provide transformations.

interface Config = {
  foo: string;

  isBad: boolean;
}

// Bad :(
type PartialConfig = {
  foo?: string;

  isBad?: boolean;
}

// Good :)
type PartialConfig = Partial<Config>;

Use union types to improve inference

// Bad :(
interface Foo { type: string }
interface FooBar extends Foo { bar: string }
interface FooZed extends Foo { zed: string }

const doThing = (foo: Foo) => {
  if (foo.type === 'bar') {
    // Casting bad :(
    console.log((foo as FooBar).bar);
  }
}

// Good :)
interface FooBar { type: 'bar', bar: string }
interface FooZed { type: 'zed', zed: string }
type Foo = FooBar | FooZed;

const doThing = (foo: Foo) => {
  if (foo.type === 'bar') {
    // No casting needed :) - TS knows we are FooBar now
    console.log(foo.bar);
  }
}

Future plans

  • Shared ESLint configuration to reuse across TypeScript projects.